Progress
Every bar has a done part: progress is a percentage of its length, and the handle at the foot of
the done part is how you change it by hand. Moving a bar keeps its percentage; resizing it asks
a question, because the bar is now longer or shorter: what stays, the part that is done or the part
that is left? task.progressAnchor answers it. This chart has the same bar twice, with the answer
'start' (the default) and 'end', a bar in a colour of its own, and a box that decides whether the
handle skips weekends.
pnpm add @ganttlane/dom # vanillapnpm add @ganttlane/react react react-dom # Reactnpm install @ganttlane/dom # vanillanpm install @ganttlane/react react react-dom # Reactyarn add @ganttlane/dom # vanillayarn add @ganttlane/react react react-dom # Reactbun add @ganttlane/dom # vanillabun add @ganttlane/react react react-dom # Reactimport type { Link, Task } from '@ganttlane/core';
// 2026-03-09 is a Monday: Build crosses a weekend (14 and 15 March). Progress is a percentage of// the length of the task, `0` to `100`; a task without `progress` has none done.export const tasks: Task[] = [ { id: 'design', name: 'Design', start: '2026-03-09', end: '2026-03-13', progress: 100 },
// The same bar twice, with the same 50 % done, to compare what a resize does to the progress. // `progressAnchor` says what a resize keeps: the part that is done (`'start'`, the default) or // the part that is left (`'end'`). { id: 'build-start', name: 'Build · anchor start', start: '2026-03-09', end: '2026-03-20', progress: 50, }, { id: 'build-end', name: 'Build · anchor end', start: '2026-03-09', end: '2026-03-20', progress: 50, progressAnchor: 'end', },
// The done part is painted in the task's own colour (`--gantt-bar-progress-color`). { id: 'review', name: 'Review', start: '2026-03-16', end: '2026-03-20', progress: 60, progressColor: '#e8590c', },
// Counted in working days: the weekend is not part of the length, so its progress is not either. { id: 'qa', name: 'QA', start: '2026-03-18', duration: 7, durationMode: 'working', progress: 40, },
{ id: 'launch', name: 'Launch', start: '2026-03-23', end: '2026-03-27' },];
export const links: Link[] = [];import type { GanttOptions } from '@ganttlane/dom';
export const options = { scheduling: { // Whether the progress handle skips the days off: a boundary dragged onto a weekend or a // holiday jumps to the edge of it, in the direction of the drag (default `true`). The box // above the chart changes it. It matters for the tasks that count calendar days: in a task // that counts working days the boundary never rests on a day off anyway. skipNonWorkingDays: { progress: true }, }, // A narrower day column than the default 40 px, so that the whole plan fits. viewModes: { day: { pxPerDay: 30 } }, scroll: { // Open on the plan; the default, 'today', is far from these dates. initial: 'first-task', // Where the first task lands, as a fraction of the width of the chart (default 0, the left // edge). A drag near an edge of the chart scrolls it, so the bars get a margin to be dragged // by their left edges. align: 0.08, },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';
import { createGantt, type Gantt } from '@ganttlane/dom';
import { links, tasks } from './data';import { describe, describeUnchanged } from './helpers';import { options } from './options';
export function mount(container: HTMLElement): () => void { const root = document.createElement('div'); const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); // The chart fills its container and scrolls inside it, so the container needs a height. const chart = document.createElement('div'); chart.style.height = '370px'; const status = document.createElement('p'); status.textContent = 'Drag the little handle at the foot of the done part of a bar.'; root.append(toolbar, chart, status); container.append(root);
// What the change was, from `onTasksChange`, for the sentence `onTaskProgressChange` writes. let reason = ''; const gantt: Gantt = createGantt(chart, { tasks, links, ...options, // One event per change, whatever it was (a move, a resize, the handle)... onTasksChange: (changeSet): void => { reason = changeSet.reason; status.textContent = describeUnchanged(reason); }, // ...and one per task whose progress changed, after it: a resize rebases the percentage. onTaskProgressChange: (record, progress): void => { status.textContent = describe(record, progress, reason); }, });
const controller = new AbortController(); const { signal } = controller;
// `skipNonWorkingDays` is one object: it is given whole, and `setOptions` replaces it. const skip = document.createElement('input'); skip.type = 'checkbox'; skip.checked = options.scheduling.skipNonWorkingDays.progress; skip.addEventListener( 'change', () => gantt.setOptions({ scheduling: { skipNonWorkingDays: { progress: skip.checked } } }), { signal }, ); const skipLabel = document.createElement('label'); skipLabel.append(skip, ' Skip days off for progress'); toolbar.append(skipLabel);
const reset = document.createElement('button'); reset.type = 'button'; reset.textContent = 'Reset'; reset.addEventListener( 'click', () => { gantt.setTasks(tasks); status.textContent = 'Back to the start.'; }, { signal }, ); toolbar.append(reset);
return () => { controller.abort(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';
import { Gantt, type GanttHandle } from '@ganttlane/react';import { useRef, useState } from 'react';
import { links, tasks } from './data';import { describe, describeUnchanged } from './helpers';import { options } from './options';
export default function Example() { const chart = useRef<GanttHandle>(null); const [skip, setSkip] = useState<boolean>(options.scheduling.skipNonWorkingDays.progress); const [status, setStatus] = useState( 'Drag the little handle at the foot of the done part of a bar.', ); // What the change was, from `onTasksChange`, for the sentence `onTaskProgressChange` writes. const reason = useRef('');
return ( <> <div role="toolbar"> <label> <input type="checkbox" checked={skip} onChange={(event) => setSkip(event.target.checked)} />{' '} Skip days off for progress </label> {/* The ref is the chart's methods. */} <button type="button" onClick={() => { chart.current?.setTasks(tasks); setStatus('Back to the start.'); }} > Reset </button> </div> <Gantt ref={chart} // The chart fills its container and scrolls inside it, so the container needs a height. style={{ height: 370 }} // The chart owns the data (uncontrolled): these are where it starts. defaultTasks={tasks} defaultLinks={links} {...options} // In React the props are the truth: `scheduling` is passed from state after the spread, // not changed with `setOptions` on the ref, which the next render would overwrite. scheduling={{ skipNonWorkingDays: { progress: skip } }} // One event per change, whatever it was (a move, a resize, the handle)... onTasksChange={(changeSet) => { reason.current = changeSet.reason; setStatus(describeUnchanged(changeSet.reason)); }} // ...and one per task whose progress changed, after it: a resize rebases the percentage. onTaskProgressChange={(record, progress) => { setStatus(describe(record, progress, reason.current)); }} /> <p>{status}</p> </> );}import type { TaskRecord } from '@ganttlane/core';
const day = new Intl.DateTimeFormat('en', { weekday: 'short', day: 'numeric', month: 'short' });
/** * Says what a change did to the progress of a task, from what `onTaskProgressChange` reports: the * new percentage, and the date the done part reaches (`progressDate`, from the computed record). * * @param record - The task, as `onTaskProgressChange` gives it. * @param progress - Its new progress in percent (not rounded: `100 / 3` stays as it is). * @param reason - What the change was: `'progress'` (the handle), `'resize-start'`, `'resize-end'`. * @returns A sentence for the status line. */export function describe(record: TaskRecord, progress: number, reason: string): string { const date = record.progressDate ? `, up to ${day.format(record.progressDate)}` : ''; return `${reason}: ${record.name} is ${String(Math.round(progress))}% done${date}.`;}
/** * Says what a change that did not touch the progress was: a move keeps the percentage. * * @param reason - What the change was. * @returns A sentence for the status line. */export function describeUnchanged(reason: string): string { return `${reason}: the progress is as it was.`;}Files
data.ts: six tasks with theirprogress,progressAnchorandprogressColor.options.ts:scheduling.skipNonWorkingDays.progressand the day column.helpers.ts: the sentences of the status line.vanilla.tsandreact.tsx: the chart, a box and a reset button.
Options
| Option | Value | What it does |
|---|---|---|
task.progress |
0 to 100 |
How much of the bar is done |
task.progressAnchor |
'end' on one Build |
What a resize keeps: the done part ('start', default) or the remainder |
task.progressColor |
'#e8590c' on Review |
The colour of the done part (--gantt-bar-progress-color) |
scheduling.skipNonWorkingDays.progress |
true |
The handle skips the days off (default true; the box) |
viewModes |
{ day: { pxPerDay: 30 } } |
A narrower day column than the default 40 px, so that the plan fits |
scroll.initial |
'first-task' |
Opens on the plan; the default, 'today', is far from these dates |
scroll.align |
0.08 |
A margin to the left of the first task (a fraction of the width), see the notes |
onTasksChange |
a function (in the example) | One ChangeSet per change; its reason is what the change was |
onTaskProgressChange |
a function (in the example) | Called with the TaskRecord and the new progress of a task whose progress changed |
What to try
- Drag the progress handle of Design, Review or any bar: it appears at the foot of the done part when you hover the bar. The status line says what the progress is now and the date the done part reaches.
- Drag the right edge of both Build bars two days to the right. They had 50 % of 12 days done. Build · anchor start keeps the six days that are done, so the percentage drops (six of 14, 43 %). Build · anchor end keeps the six that are left, so the percentage rises (eight of 14, 57 %). Drag the edge two days to the left and the two swap sides (60 % and 40 %).
- Drag the left edge of both and it is the other way around. The done part of the anchor
'start'bar moves with its start edge, and that of the anchor'end'bar stays where it is. - Move a bar. The percentage is kept, and the status line says the progress is as it was
(
onTaskProgressChangeis not called). - Drag the handle of Build · anchor start back to a weekday, and then forward onto the Sunday. With the box ticked the boundary jumps over the days off, to the Monday after them (58 %); with it unticked it rests on the Sunday (50 %). Going the other way it would jump to the Saturday.
- Do the same with QA. It counts working days, so its boundary never rests on a day off, with the box ticked or not: it stops at the start of the weekend.
Notes
- A resize rebases the progress. With
'start'the done amount (in days, or in working days for a task that counts them) stays, and the percentage changes:progress × old / new. With'end'the remainder stays:100 − (100 − progress) × old / new. Shrinking a bar below what is done gives 100 %. The percentage is kept exact, not rounded, so repeated resizes do not drift; only the date of the boundary is aligned to the data step. - Events. A change is one
ChangeSet(onTasksChange, with areason:'progress'for the handle,'resize-start','resize-end','move', …). After it,onTaskProgressChangeis called for each task whose progress changed, with its record:record.progressandrecord.progressDate, the date the done part reaches. There is no event calledprogressChange. - Skipping days off (
scheduling.skipNonWorkingDays.progress) belongs to the handle. It is a field of the same object asmoveandresize, which the example leaves as they are (the object is replaced whole bysetOptions, so the fields it leaves out are back to their defaults). - The colour.
progressColorsets the custom property--gantt-bar-progress-coloron the bar;colordoes the same for the bar itself. Both can be set in your stylesheet too. - A margin on the left. A drag that comes near an edge of the chart scrolls it (
scroll.autoScroll), so a bar that starts at the very edge cannot be taken by its left edge without the chart moving under it.scroll.align: 0.08leaves the first bars a margin. - Editing.
editing: { progress: false }turns the handle off for the chart, and a task’s owneditabledoes it for one task. - React.
schedulingis passed to<Gantt>after the spread ofoptions, from state: in React the props are the truth, so an option changed withsetOptionson the ref would be overwritten by the next render.