Milestones and placeholders
Not every row of a plan is a bar. A milestone (type: 'milestone') is a point in time with no
length, drawn as a diamond. A placeholder (type: 'placeholder') is a row with no bar at all: a
heading for the rows under it, or work that nobody has scheduled yet. The plan here chains five
milestones and four tasks with links, and keeps three placeholder rows.
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';
// A plan with five milestones and three placeholder rows. 2026-03-02 is a Monday.// `type: 'milestone'` a point in time, not a bar: it has a `start` and no length (an `end` or// a `duration` is ignored). It is drawn as a diamond.// `type: 'placeholder'` a row with no bar: its dates, if it has any, are ignored. It holds a// place in the list: a heading for the rows under it, or work that is not// scheduled yet. A task with no dates at all is a placeholder too, but// the chart reports it as an issue; the type says you meant it.export const tasks: Task[] = [ { id: 'kickoff', name: 'Kick-off', type: 'milestone', start: '2026-03-02' }, { id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-06' }, { id: 'signoff', name: 'Design sign-off', type: 'milestone', start: '2026-03-09' }, { id: 'build', name: 'Build', start: '2026-03-09', end: '2026-03-17' }, { id: 'beta', name: 'Beta', type: 'milestone', start: '2026-03-18' },
// A heading: a placeholder with children, which are the rows below it. { id: 'launch', name: 'Launch', type: 'placeholder' }, { id: 'marketing', name: 'Marketing', parentId: 'launch', start: '2026-03-20', end: '2026-03-25', }, { id: 'docs', name: 'Docs', parentId: 'launch', start: '2026-03-20', end: '2026-03-26' }, { id: 'release', name: 'Release', type: 'milestone', parentId: 'launch', start: '2026-03-27' },
// Work that is not scheduled yet: rows with no bar. The button turns Hiring into a task. { id: 'backlog', name: 'Backlog', type: 'placeholder' }, { id: 'hiring', name: 'Hiring', type: 'placeholder', parentId: 'backlog' }, { id: 'support', name: 'Support rota', type: 'placeholder', parentId: 'backlog' },];
// A milestone is linked like any task: it is the source of a link (what follows it) or its target// (what it waits for). Its two edges are the same moment, so every link type reaches the same point.export const links: Link[] = [ { source: 'kickoff', target: 'design', lag: 0 }, { source: 'design', target: 'signoff', lag: 2 }, { source: 'signoff', target: 'build', lag: 0 }, { source: 'build', target: 'beta', lag: 0 }, { source: 'beta', target: 'marketing', lag: 2 }, { source: 'beta', target: 'docs', lag: 2 }, { source: 'marketing', target: 'release', lag: 1 }, { source: 'docs', target: 'release', lag: 0 },];import type { GanttOptions } from '@ganttlane/dom';
export const options = { // Every day is a working day, so a milestone on a Saturday is not a conflict. calendar: { weekends: [] }, // Compact rows, so that all twelve show. layout: { rowHeight: 34, barHeight: 22 }, // One column a week: the whole plan fits. viewMode: 'week', scroll: { // Open on the plan; the default, 'today', is far from these dates. initial: 'first-task', // Where the first task lands in the chart, as a fraction of its width (default 0, the left // edge): a little in, so that the first diamond is not cut in half by the edge. align: 0.04, },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';
import { createGantt, type Gantt, type Task } from '@ganttlane/dom';
import { links, tasks } from './data';import { describe, describeTask, toggleHiring } 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 = '490px'; const status = document.createElement('p'); status.textContent = 'Click a milestone or a bar, or drag Kick-off.'; root.append(toolbar, chart, status); container.append(root);
// The handlers read `gantt`, which they are passed to: give it a type. const gantt: Gantt = createGantt(chart, { tasks, links, ...options, // The record says what the row is: `type`, and a `duration` of 0 for a milestone. onTaskClick: (record): void => { status.textContent = describeTask(record); }, // One event per drag: the dragged task first, then the tasks its links moved. onTasksChange: (changeSet): void => { status.textContent = describe(changeSet, (id) => gantt.getTask(id)?.name ?? String(id)); }, });
const controller = new AbortController(); const { signal } = controller;
const addButton = (label: string, onClick: () => void): HTMLButtonElement => { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.addEventListener('click', onClick, { signal }); toolbar.append(button); return button; };
// The tasks are yours: a new list goes in with `setTasks`, and the chart diffs it by id. let current: Task[] = [...tasks]; const schedule = addButton('Schedule Hiring', () => { current = toggleHiring(current); gantt.setTasks(current); const scheduled = gantt.getTask('hiring')?.type === 'task'; schedule.textContent = scheduled ? 'Unschedule Hiring' : 'Schedule Hiring'; status.textContent = scheduled ? 'Hiring is a task now: it has dates and a bar.' : 'Hiring is a placeholder again: a row with no bar.'; }); addButton('Reset', () => { current = [...tasks]; gantt.setTasks(current); schedule.textContent = 'Schedule Hiring'; status.textContent = 'Back to the start.'; });
return () => { controller.abort(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';
import { Gantt, type GanttHandle, type Task } from '@ganttlane/react';import { useRef, useState } from 'react';
import { links, tasks } from './data';import { describe, describeTask, toggleHiring } from './helpers';import { options } from './options';
export default function Example() { const chart = useRef<GanttHandle>(null); // The tasks are yours: a new list goes in with `setTasks` on the ref, and the chart diffs it by id. const [current, setCurrent] = useState<Task[]>([...tasks]); const [scheduled, setScheduled] = useState(false); const [status, setStatus] = useState('Click a milestone or a bar, or drag Kick-off.');
return ( <> <div role="toolbar"> <button type="button" onClick={() => { const next = toggleHiring(current); const nowScheduled = !scheduled; setCurrent(next); setScheduled(nowScheduled); chart.current?.setTasks(next); setStatus( nowScheduled ? 'Hiring is a task now: it has dates and a bar.' : 'Hiring is a placeholder again: a row with no bar.', ); }} > {scheduled ? 'Unschedule Hiring' : 'Schedule Hiring'} </button> <button type="button" onClick={() => { setCurrent([...tasks]); setScheduled(false); 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: 490 }} // The chart owns the data (uncontrolled): these are where it starts. defaultTasks={tasks} defaultLinks={links} {...options} // The record says what the row is: `type`, and a `duration` of 0 for a milestone. onTaskClick={(record) => setStatus(describeTask(record))} // One event per drag: the dragged task first, then the tasks its links moved. onTasksChange={(changeSet) => { setStatus(describe(changeSet, (id) => chart.current?.getTask(id)?.name ?? String(id))); }} /> <p>{status}</p> </> );}import type { ChangeSet, Task, TaskId, TaskRecord } from '@ganttlane/core';
import { tasks as original } from './data';
const DAY = 24 * 60 * 60 * 1000;
const day = new Intl.DateTimeFormat('en', { weekday: 'short', day: 'numeric', month: 'short' });
/** What Hiring becomes when the button schedules it: a task of five days. */const SCHEDULED: Task = { id: 'hiring', name: 'Hiring', parentId: 'backlog', start: '2026-03-20', duration: 5,};
/** * Schedules Hiring (a placeholder row becomes a bar), or puts it back as it is in `data.ts`. * The tasks you pass are not changed: the result is a new array, to give to `setTasks`. * * @param list - The tasks you keep. * @returns The new list. */export function toggleHiring(list: readonly Task[]): Task[] { const placeholder = original.find((task) => task.id === 'hiring'); const now = list.find((task) => task.id === 'hiring'); const replacement = now?.type === 'placeholder' ? SCHEDULED : placeholder; return replacement ? list.map((task) => (task.id === 'hiring' ? replacement : task)) : [...list];}
/** * Says what a click found: the kind of the row, and where it is. * * @param record - The task that was clicked, from `onTaskClick`. * @returns A sentence for the status line. */export function describeTask(record: TaskRecord): string { if (!record.start || !record.duration) return `${record.name}: a ${record.type} row, no bar.`; if (record.type === 'milestone') { return `${record.name}: a milestone on ${day.format(record.start)}, no length.`; } return `${record.name}: a task from ${day.format(record.start)}, ${String(record.duration.calendarDays)} days.`;}
/** * Says what a drag changed: the dragged task and the tasks the links moved with it. * * @param changeSet - What `onTasksChange` reports. Its dates are in core's floating milliseconds. * @param nameOf - The name of a task, from the chart. * @returns A sentence for the status line. */export function describe(changeSet: ChangeSet, nameOf: (id: TaskId) => string): string { const [dragged, ...followers] = changeSet.changes; if (!dragged) return 'Nothing changed.'; const shift = (dragged.after.start - dragged.before.start) / DAY; let text = `${changeSet.reason}: ${nameOf(dragged.id)} (${shift > 0 ? '+' : ''}${String(shift)}d).`; if (followers.length > 0) { text += ` Followed: ${followers.map((change) => nameOf(change.id)).join(', ')}.`; } return text;}Files
data.ts: the tasks, withtypeandparentId, and the links, in comments.options.ts: the calendar and the view mode.helpers.ts: what the button does to the tasks, and the sentences of the status line.vanilla.tsandreact.tsx: the chart and two buttons.
Options
| Option | Value | What it does |
|---|---|---|
task.type |
'milestone' |
A point in time at start; a zero length; drawn as a diamond |
task.type |
'placeholder' |
A row with no bar; its dates are ignored |
task.parentId |
'launch', 'backlog' |
A placeholder can be the group of the rows under it |
link |
{ source, target, lag } |
A milestone is the source or the target of a link, like a task |
calendar.weekends |
[] |
Every day is a working day, so a milestone on a Saturday is no conflict |
layout.rowHeight, layout.barHeight |
34, 22 |
Compact rows, so that all twelve show |
viewMode |
'week' |
One column a week: the whole plan fits |
scroll.initial |
'first-task' |
Opens on the plan; the default, 'today', is far from these dates |
scroll.align |
0.04 |
Where the first task lands, as a fraction of the width: a little in from the edge, so that the first diamond is whole |
onTaskClick |
a function (in the example) | Called with the TaskRecord of a bar that was clicked |
onTasksChange |
a function (in the example) | One ChangeSet per drag, with every task it moved |
What to try
- Drag Kick-off a day or two to the right. Every link after it moves its target: Design, then Design sign-off, Build, Beta, Marketing, Docs and Release. The status line lists them.
- Take a diamond by its side. There is nothing to resize: a milestone has no edges to drag, so it moves, and that is all it does.
- Click Beta, and then Build. The status line reads the record:
type, the date, and the length (none for a milestone). - Drag Marketing to the right. Release, the milestone that waits for it (and for Docs), follows it by its lag: a milestone is moved by links like any bar.
- Press Schedule Hiring. Hiring was a placeholder, a row under Backlog with no bar; now it is a task with a start and a duration, and it has a bar. Press it again and the bar goes, and the row stays. The other placeholders, Launch and Support rota, have no bar either, and nothing to drag.
Notes
- A milestone has a
startand no length. Itsendanddurationare ignored. Its start and its end are the same moment, so a link of any type reaches the same point;lagcounts from it. A milestone cannot be resized, but it can be moved, linked and given aprogress. - A placeholder has no bar. A row without dates is a placeholder too, but then the chart
reports a
no-datesissue: the type says you meant it. A link that touches a placeholder is kept but inactive: it is not drawn, not propagated, and reported as aninactive-linkissue. Give the row dates and a tasktype(as the button does) and the link works. - A placeholder as a heading.
parentIdmakes Launch the parent of three rows, a milestone among them; the heading has no bar, so a child is never “outside” it. Children of a placeholder can be shut withcollapse('launch')as any other group (see Groups and children). - Your tasks, your list. The button builds a new array with the one task replaced and gives it
to
setTasks; the chart diffs it byid. Nothing you pass in is changed. - React.
defaultTasksanddefaultLinksare where the chart starts, and then it owns the data (uncontrolled);chart.current.setTasks(next)gives it a new list.