Groups and children
A task with parentId is a child; a task that has children is a group (a phase here). The rows
are the tasks in pre-order, a group followed by its children, and each group is open or shut. This
chart has three phases and the controls that decide what happens to their children: whether they
move with the phase, and which edges of a child stay pinned to which edges of its phase.
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';
// Three phases, each with children: `parentId` makes the tree, and the rows are the tasks in// pre-order (a phase, then its children). 2026-03-02 is a Monday; a task with an `end` ends on// that day (inclusive).//// A child is tied to its phase in two steps:// `followParent: true` the child moves with its phase (when `hierarchy.moveChildren` lets it);// `parentAnchor` which edges of the child are pinned to which edges of the phase:// 'SS' start to start, 'FF' end to end, 'FS' start to the phase's end,// 'SF' end to the phase's start, or two of them (one per edge).// A child with no `parentAnchor` takes `hierarchy.childAnchor`.export const tasks: Task[] = [ // ---- Plan: collapsed when the chart opens ---- { id: 'plan', name: 'Plan', start: '2026-03-02', end: '2026-03-06' }, { id: 'research', name: 'Research', parentId: 'plan', start: '2026-03-02', end: '2026-03-04', parentAnchor: 'SS', // its start is pinned to the start of Plan followParent: true, }, { id: 'spec', name: 'Spec', parentId: 'plan', start: '2026-03-04', end: '2026-03-06', parentAnchor: 'FF', // its end is pinned to the end of Plan followParent: true, },
// ---- Build: expanded, and the one to drag ---- { id: 'build', name: 'Build', start: '2026-03-09', end: '2026-03-20' }, { id: 'backend', name: 'Backend', parentId: 'build', start: '2026-03-09', end: '2026-03-16', parentAnchor: 'SS', // start pinned: it moves with Build, and a resize of Build's end leaves it followParent: true, }, { id: 'frontend', name: 'Frontend', parentId: 'build', start: '2026-03-09', end: '2026-03-20', parentAnchor: ['SS', 'FF'], // both edges pinned: it stretches with Build followParent: true, }, { id: 'qa', name: 'QA', parentId: 'build', start: '2026-03-16', end: '2026-03-20', followParent: true, // no `parentAnchor`: the pin is `hierarchy.childAnchor` }, { id: 'docs', name: 'Docs', parentId: 'build', start: '2026-03-11', end: '2026-03-18', // Not followParent: only `moveChildren: 'all'` moves it, with the start of Build },
// ---- Launch: collapsed when the chart opens ---- { id: 'launch', name: 'Launch', start: '2026-03-23', end: '2026-03-27' }, { id: 'marketing', name: 'Marketing', parentId: 'launch', start: '2026-03-25', end: '2026-03-27', parentAnchor: 'FF', followParent: true, }, { id: 'support', name: 'Support', parentId: 'launch', start: '2026-03-26', end: '2026-03-27', followParent: true, },];
export const links: Link[] = [];import type { GanttOptions } from '@ganttlane/dom';
export const options = { // Every day is a working day, so a bar on a weekend is not a conflict here: the conflicts you // see are about the hierarchy. calendar: { weekends: [] }, hierarchy: { // Which children move with their phase: 'all' (the default), 'following' (only those with // `followParent`) or 'none'. The first select changes it. moveChildren: 'all', // The pin of a child that has `followParent` and no `parentAnchor` of its own: 'SS' (the // default), 'FF', 'FS', 'SF', or two of them. The second select changes it. childAnchor: 'FF', // The phases that are open: 'all' (the default) or their ids. Build is open, Plan and Launch // are shut; `expand`, `collapse`, `expandAll` and `collapseAll` change it later. expandedKeys: ['build'], }, // Zebra stripes for each phase with its children. display: { rowStripes: 'group' }, // One column a week: the whole plan fits. viewMode: 'week', scroll: { initial: 'first-task' },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';
import { createGantt, type Gantt } from '@ganttlane/dom';
import { links, tasks } from './data';import { CHILD_ANCHORS, describe, describeExpanded, MOVE_CHILDREN } 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 = '420px'; const status = document.createElement('p'); status.textContent = 'Drag Build, then change Move children and drag it again.'; 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, // One event per drag: the dragged task first, then the children that moved with it. onTasksChange: (changeSet): void => { status.textContent = describe(changeSet, (id) => gantt.getTask(id)?.name ?? String(id)); }, // Called by `expand`, `collapse`, `expandAll` and `collapseAll`. onExpandedChange: (keys): void => { status.textContent = describeExpanded(keys, (id) => gantt.getTask(id)?.name ?? String(id)); }, });
const controller = new AbortController(); const { signal } = controller;
const addButton = (label: string, onClick: () => void): void => { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.addEventListener('click', onClick, { signal }); toolbar.append(button); };
const addSelect = ( label: string, values: readonly string[], selected: string, onChange: (value: string) => void, ): void => { const field = document.createElement('label'); const select = document.createElement('select'); for (const value of values) { const option = document.createElement('option'); option.value = option.textContent = value; option.selected = value === selected; select.append(option); } select.addEventListener('change', () => onChange(select.value), { signal }); field.append(`${label} `, select); toolbar.append(field); };
// The methods that open and shut phases (the chart has no button of its own for it). addButton('Expand all', () => gantt.expandAll()); addButton('Collapse all', () => gantt.collapseAll()); addButton('Toggle Build', () => { if (gantt.getTask('build')?.isExpanded) gantt.collapse('build'); else gantt.expand('build'); });
// Both options change without recreating the chart; they matter at the next drag. addSelect('Move children', MOVE_CHILDREN, options.hierarchy.moveChildren, (value) => { const moveChildren = MOVE_CHILDREN.find((candidate) => candidate === value); if (moveChildren) gantt.setOptions({ hierarchy: { moveChildren } }); }); addSelect( 'Child anchor', CHILD_ANCHORS.map((entry) => entry.label), options.hierarchy.childAnchor, (value) => { const entry = CHILD_ANCHORS.find((candidate) => candidate.label === value); if (entry) gantt.setOptions({ hierarchy: { childAnchor: entry.anchor } }); }, );
addButton('Reset', () => { gantt.setTasks(tasks); status.textContent = 'Back to the start.'; });
return () => { controller.abort(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';
import { Gantt, type GanttHandle, type TaskId } from '@ganttlane/react';import { useRef, useState } from 'react';
import { links, tasks } from './data';import { CHILD_ANCHORS, describe, describeExpanded, MOVE_CHILDREN } from './helpers';import { options } from './options';
export default function Example() { const chart = useRef<GanttHandle>(null); const [moveChildren, setMoveChildren] = useState<string>(options.hierarchy.moveChildren); const [childAnchor, setChildAnchor] = useState<string>(options.hierarchy.childAnchor); const [status, setStatus] = useState('Drag Build, then change Move children and drag it again.'); const nameOf = (id: TaskId): string => chart.current?.getTask(id)?.name ?? String(id);
// The open phases are a prop of their own in React (`defaultExpandedKeys`: the chart owns them // from then on, so the buttons below can change them), not a field of `hierarchy`. const { hierarchy, ...rest } = options;
return ( <> <div role="toolbar"> {/* The ref is the chart's methods. */} <button type="button" onClick={() => chart.current?.expandAll()}> Expand all </button> <button type="button" onClick={() => chart.current?.collapseAll()}> Collapse all </button> <button type="button" onClick={() => { if (chart.current?.getTask('build')?.isExpanded) chart.current.collapse('build'); else chart.current?.expand('build'); }} > Toggle Build </button> <label> Move children{' '} <select value={moveChildren} onChange={(event) => setMoveChildren(event.target.value)}> {MOVE_CHILDREN.map((value) => ( <option key={value}>{value}</option> ))} </select> </label> <label> Child anchor{' '} <select value={childAnchor} onChange={(event) => setChildAnchor(event.target.value)}> {CHILD_ANCHORS.map((entry) => ( <option key={entry.label}>{entry.label}</option> ))} </select> </label> <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: 420 }} // The chart owns the data (uncontrolled): these are where it starts. defaultTasks={tasks} defaultLinks={links} {...rest} // In React the props are the truth: the two selects change `hierarchy` through state, not // through `setOptions` on the ref, which the next render would overwrite. hierarchy={{ moveChildren: MOVE_CHILDREN.find((value) => value === moveChildren), childAnchor: CHILD_ANCHORS.find((entry) => entry.label === childAnchor)?.anchor, }} defaultExpandedKeys={hierarchy.expandedKeys} onTasksChange={(changeSet) => setStatus(describe(changeSet, nameOf))} onExpandedChange={(keys) => setStatus(describeExpanded(keys, nameOf))} /> <p>{status}</p> </> );}import type { Change, ChangeSet, TaskId } from '@ganttlane/core';
/** The values of `hierarchy.moveChildren`. */export const MOVE_CHILDREN = ['all', 'following', 'none'] as const;export type MoveChildren = (typeof MOVE_CHILDREN)[number];
/** The pins the second select offers: its label, and the value of `hierarchy.childAnchor` it sets. */export const CHILD_ANCHORS = [ { label: 'SS', anchor: 'SS' }, { label: 'FF', anchor: 'FF' }, { label: 'SS + FF', anchor: ['SS', 'FF'] },] as const;
const DAY = 24 * 60 * 60 * 1000;
/** A length of time in days with its sign: `+2d`, `-1d`. */function signedDays(ms: number): string { const days = ms / DAY; return `${days > 0 ? '+' : ''}${String(days)}d`;}
/** How far a change moved a task: one number for a move, the edges that moved for a resize. */function shift(change: Change): string { const start = change.after.start - change.before.start; const end = change.after.endExclusive - change.before.endExclusive; if (start === end) return signedDays(start); const edges = []; if (start !== 0) edges.push(`start ${signedDays(start)}`); if (end !== 0) edges.push(`end ${signedDays(end)}`); return edges.join(', ');}
/** * Says what a drag changed: the dragged task, the children that moved with it (grouped by how * far they moved), and the conflicts the result has (a child outside its phase). * * @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, ...others] = changeSet.changes; if (!dragged) return 'Nothing changed.'; let text = `${changeSet.reason}: ${nameOf(dragged.id)} (${shift(dragged)}).`; const groups = new Map<string, string[]>(); for (const change of others) { const names = groups.get(shift(change)) ?? []; names.push(nameOf(change.id)); groups.set(shift(change), names); } for (const [how, names] of groups) text += ` Followed: ${names.join(', ')} (${how}).`; if (others.length === 0 && dragged.cause === 'direct') text += ' No other task moved.'; for (const conflict of changeSet.conflicts) { text += ` Conflict: ${nameOf(conflict.id)} ${conflict.message}.`; } return text;}
/** * Says which phases are open. * * @param keys - What `onExpandedChange` reports: `'all'` or the ids of the open phases. * @param nameOf - The name of a task, from the chart. * @returns A sentence for the status line. */export function describeExpanded( keys: 'all' | readonly TaskId[], nameOf: (id: TaskId) => string,): string { if (keys === 'all') return 'Open: every phase.'; return keys.length === 0 ? 'Open: none.' : `Open: ${keys.map(nameOf).join(', ')}.`;}Files
data.ts: the three phases and their children, withfollowParentandparentAnchor.options.ts:hierarchy(moveChildren,childAnchor,expandedKeys), the calendar and the row stripes.helpers.ts: the values the selects offer, and sentences for the status line.vanilla.tsandreact.tsx: the chart, the buttons and the selects.
Options
| Option | Value | What it does |
|---|---|---|
hierarchy.moveChildren |
'all' |
Which children move with their phase: 'all', 'following' or 'none' (the select) |
hierarchy.childAnchor |
'FF' |
The pin of a child with followParent and no parentAnchor (default 'SS'; the select) |
hierarchy.expandedKeys |
['build'] |
Which phases are open (React: defaultExpandedKeys); Plan and Launch start shut |
task.followParent |
true |
The child moves with its phase, by its parentAnchor |
task.parentAnchor |
'SS', 'FF', ['SS', 'FF'] |
Which edges of the child are pinned to which edges of the phase |
calendar.weekends |
[] |
Every day is a working day, so the conflicts you see are about the hierarchy |
display.rowStripes |
'group' |
Zebra stripes for each phase with its children |
viewMode |
'week' |
One column a week: the whole plan fits |
onExpandedChange |
a function (in the example) | Called with the open phases after expand, collapse, expandAll, collapseAll |
onTasksChange |
a function (in the example) | One ChangeSet per drag, with every child it moved |
What to try
- Drag Build with Move children on
all. Backend, Frontend and QA follow by their pins and Docs follows with the start of Build. The status line lists what moved. - Change Move children to
followingand drag Build again: Docs stays, because it has nofollowParent. Withnonenothing follows, and the children that are left outside Build are drawn with a red outline: that is theoutside-parentconflict, reported in the status line. - Drag the right edge of Build. Frontend (pinned at both edges) stretches with it, QA
(pinned by its end, from
childAnchor: 'FF') moves, and Backend (pinned by its start only) stays. Change Child anchor toSSand try again: QA now stays and follows the start instead. - Collapse Build and drag it. The children you cannot see move all the same; expand it again to
check. Toggle Build, Expand all and Collapse all call
expand,collapse,expandAllandcollapseAll, andonExpandedChangereports the open phases. - Drag a child (say Backend) past the edge of Build: it is free to go, and the conflict says which edge it crossed.
Notes
- Rows. A child follows its phase in the task list. While its phase is shut the child has no
row and no bar (
rowIndexof its record isundefined), and it still moves with the phase. moveChildren.'all'moves the children withfollowParentby their pin, and every other child with the start of its phase (it keeps its distance from it).'following'moves only the children withfollowParent.'none'moves no child; the children stay where they were, and may end up outside their phase.- Pins.
'SS'pins the start of the child to the start of its phase,'FF'the end to the end,'FS'the start to the end of the phase and'SF'the end to its start. Give two (one per edge,['SS', 'FF']) to pin both: the child then stretches with the phase. The distance a child had from its phase when the data was loaded is kept. - Conflicts are warnings. A child outside its phase is accepted data: it is flagged on the bar
(
data-conflict), listed inrecord.conflictsand inchangeSet.conflicts, and passed toonBeforeTaskChange, which may refuse it (see A release plan). - React. The open phases are a prop of their own:
expandedKeysis controlled, anddefaultExpandedKeysis where an uncontrolled chart starts (the buttons then work through theref).hierarchytakes onlymoveChildrenandchildAnchor, and is passed from state after the spread ofoptions: in React the props are the truth, so an option changed withsetOptionson the ref would be overwritten by the next render.