Hierarchy
Tasks nest through parentId. A parent is a row that has children under it; collapsing it hides
them, and moving it can move them. This page covers how the tree is built, which rows are visible,
how children follow a parent, and how the chart reports a parent that does not exist or loops back
on itself. After it you can model phases with children, control which of them move with their
phase, and drive expand and collapse from code.
Building the tree
Section titled “Building the tree”Give a child the id of its parent in parentId:
import { createGantt } from '@ganttlane/dom';
const gantt = createGantt(container, { tasks: [ { id: 'ui', name: 'UI', parentId: 'build', start: '2026-03-09', end: '2026-03-10' }, { id: 'build', name: 'Build', start: '2026-03-02', end: '2026-03-13' }, { id: 'api', name: 'API', parentId: 'build', start: '2026-03-04', end: '2026-03-05' }, { id: 'ship', name: 'Ship', type: 'milestone', start: '2026-03-16' }, ],});
gantt.getTask('build')?.childIds; // ['ui', 'api']gantt.getTask('api')?.level; // 1gantt.getTask('api')?.parentId; // 'build'gantt.getTask('api')?.rowIndex; // 2- Any task can be a parent. There is no
typefor it: a task that others name inparentIdhas children. ItsTaskRecordshowschildIds,level(0for a root),rowIndexandisExpanded. - Row order is the tree in pre-order. Each parent is followed by its whole subtree, and
siblings and roots keep the order they have in
tasks. It does not matter whether a child is listed before or after its parent: the rows above arebuild,ui,api,ship. - There is no depth limit, and building the tree takes time linear in the number of tasks.
parentIdhas the type of anid:5and'5'are different tasks.
Three options shape the tree; they are the hierarchy group, and a prop of the same name in React
(except expandedKeys, which is a prop of its own).
| Option | Type | Default | Meaning |
|---|---|---|---|
hierarchy.expandedKeys |
'all' | TaskId[] |
'all' |
Which parents are expanded |
hierarchy.childAnchor |
link type or list of link types | 'SS' |
The default parentAnchor of a pinned child |
hierarchy.moveChildren |
'all' | 'following' | 'none' |
'all' |
Which children move when their parent moves |
Options: Hierarchy has the details of each.
Rows, expand and collapse
Section titled “Rows, expand and collapse”A chart shows one row per visible task. A parent is expanded when its children are shown.
hierarchy.expandedKeys is 'all' (every parent open, and parents that appear later start open) or
the list of the ids that are open. Ids of tasks without children are allowed and do nothing until the
task gets a child; unknown ids are ignored. A collapsed parent hides its whole subtree,
whatever the keys say about the descendants.
createGantt(container, { tasks, hierarchy: { expandedKeys: ['build'] }, // only Build is open onExpandedChange: (expandedKeys) => console.log(expandedKeys),});A hidden row has no place in the chart: its rowIndex is undefined, getRowMetrics(id)
returns undefined, and its bar is not drawn. A link to a hidden task is still scheduled, but it is
not drawn either; it is not re-pointed at the parent’s bar.
Changing the open parents
Section titled “Changing the open parents”| From code | What it does |
|---|---|
gantt.expand(id), gantt.collapse(id) |
Open or close one parent |
gantt.expandAll(), gantt.collapseAll() |
Open every parent that exists now, or close them all |
gantt.scrollToTask(id) |
Scrolls to the task and opens its collapsed ancestors (expand: false leaves them) |
gantt.setOptions({ hierarchy: { expandedKeys } }) |
Replaces the open parents |
onExpandedChange(expandedKeys) |
Reports a change made by one of the first three |
- The value
'all'becomes a list at the firstcollapse(id): it turns into every other parent.expandAll()gives the list of the parents that exist at that moment, so a parent added later starts closed, unlike'all'. setOptionsdoes not fireonExpandedChange. Setting the value the option already has ('all'again, or the same array object) changes nothing, even if rows were opened or closed since: pass a new array.- The chart itself draws no expand arrow: it has bars only. The
task list stands beside it, with a toggle on each parent. Or build your own (a
button per parent, or a list of your own as in the
task list beside the chart example) and call these methods
from it.
display.rowStripescolours rows per row or per top-level group.
const gantt = createGantt(container, { tasks, hierarchy: { expandedKeys: [] } });
document.querySelector('#toggle-build')?.addEventListener('click', () => { const open = gantt.getTask('build')?.isExpanded; if (open) gantt.collapse('build'); else gantt.expand('build');});import { useState } from 'react';import { Gantt, type ExpandedKeys } from '@ganttlane/react';
export function Plan() { const [open, setOpen] = useState<ExpandedKeys>([]); return ( <Gantt tasks={tasks} expandedKeys={open} onExpandedChange={setOpen} style={{ height: 420 }} /> );}With expandedKeys the prop is the truth: a call such as handle.collapse('build') fires
onExpandedChange, and the chart returns to the prop if your state does not change.
defaultExpandedKeys leaves the chart in charge.
Pinned children and free children
Section titled “Pinned children and free children”When a parent moves or is resized, its children may follow. A child can follow in two ways.
- A pinned child has
followParent: true. Its edges are tied to the parent’s edges throughparentAnchor(defaulthierarchy.childAnchor,'SS'), and keep the distance they had in your data. - A free child has no
followParent. It is not tied to any edge, but with the defaultmoveChildren: 'all'it shifts with the parent’s start: when the parent’s start moves, it moves by the same amount, keeping its distance from that start.
parentAnchor is one link type or a list, read from the child’s side:
| Value | The child’s … | follows the parent’s … |
|---|---|---|
'SS' |
start | start |
'FS' |
start | end |
'FF' |
end | end |
'SF' |
end | start |
A list pins both edges: one type for the start (SS or FS) and one for the end (FF or SF),
such as ['SS', 'FF']. A child pinned that way stretches with its parent.
hierarchy.moveChildren decides which children take part:
moveChildren |
Pinned children | Free children |
|---|---|---|
'all' (default) |
follow their parentAnchor |
shift when the parent’s start changes |
'following' |
follow their parentAnchor |
stay |
'none' |
stay | stay |
With the tasks above, where api is pinned (followParent: true) and ui is free, moving Build
one week later (gantt.updateTask('build', { start: '2026-03-09' })) gives:
moveChildren |
Build | api (pinned, SS) |
ui (free) |
|---|---|---|---|
'all' |
Mar 9 to Mar 20 | Mar 11 to Mar 12 | Mar 16 to Mar 17 |
'following' |
Mar 9 to Mar 20 | Mar 11 to Mar 12 | Mar 9 to Mar 10 |
'none' |
Mar 9 to Mar 20 | Mar 4 to Mar 5 | Mar 9 to Mar 10 |
Resizing Build’s end to March 20 instead shows what the anchor does to the pinned child:
parentAnchor of api |
api becomes |
|---|---|
'SS' (default) |
Mar 4 to Mar 5: only the start is tied |
'FF' |
Mar 11 to Mar 12: the end follows, the length is kept |
['SS', 'FF'] |
Mar 4 to Mar 12: the child stretches |
A locked child never moves and stops the change there: its own children do not move through it.
A fixedDuration child keeps its length. A change reaches grandchildren through the children that
move. Whatever you do to a child, its parent is never changed: moving a child out of its parent
only raises a conflict, below. The whole change is one ChangeSet whose changes carry
cause: 'parent' and the parent as via; the details, and what happens when a link and a parent
pin the same edge, are in Propagation and conflicts.
The hierarchy example and the release plan let you try each setting.
A child outside its parent
Section titled “A child outside its parent”A child that starts before its parent or ends after it has an outside-parent conflict on its
record, with the edges that stick out ('start', 'end') and the parentId. It is a warning:
the chart accepts the data, and onBeforeTaskChange sees the conflict of an edit that causes it, so
a hook can refuse. A milestone child may sit exactly on either edge of its parent. A child of a
placeholder row is never outside it, because the placeholder has no bar.
A parent is not computed from its children
Section titled “A parent is not computed from its children”A parent’s dates are its own. The chart does not stretch a parent to cover its children or derive
its dates from them. Not available yet: summary bars whose dates roll up from the children. If you
want one, set the parent’s start and end yourself, for example when you build the tasks, and
let the outside-parent conflict tell you when a child leaves it.
A parent can also be a row without a bar. Give it type: 'placeholder' (or no dates) and it is
a heading: it has a row, a toggle state and children, but no bar, nothing moves with it, and its
children are never outside it.
Milestones and rows without a bar
Section titled “Milestones and rows without a bar”Every task takes part in the tree, whatever its type:
- A milestone can be a child. Pinned to an edge, it keeps its offset like any child.
- A placeholder or a task with no dates is a row like the others, with its own children and place in the order. It has no bar, so a pinned child of it does not follow anything and a link to it is inactive.
- An invalid task (a bad date,
incomplete-dates) keeps its row, itsparentIdand its children.
See Tasks and records for the types.
Bad parents
Section titled “Bad parents”The tree is repaired, never thrown away. Each repair is reported as an issue on the task and in
getIssues().data:
| Issue | Severity | What happened | The result |
|---|---|---|---|
unknown-parent |
warning | parentId matches no task. value is the id that was not found |
The task is a root |
parent-cycle |
error | Parents form a loop (a to b to a), or a task is its own parent. value lists the members |
The member that comes first in tasks becomes a root; the others keep their parents, so the loop turns into a chain under it |
invalid-parent-anchor |
warning | parentAnchor (or hierarchy.childAnchor, at that path) is not a link type, is empty, or pins one edge twice (['SS', 'FS']) |
Unusable entries are ignored; if none is left, the default is used. suggested is what is applied |
gantt.getIssues().data.find((issue) => issue.code === 'parent-cycle');// { code: 'parent-cycle', severity: 'error', path: 'parentId', taskId: 'loop-a',// value: ['loop-a', 'loop-b'], message: '...' }A link between a task and its own ancestor can close a cycle with the parent-to-child edges of the tree. Propagation ignores the links on such a cycle and follows the rest. A repeated task id drops the later task, so children that name that id attach to the first.
Related
Section titled “Related”- Tasks and records:
level,childIds,rowIndexandisExpandedon a record, and the task types. - Propagation and conflicts: how a parent’s edit spreads to its children and along links.
- Links and link types: the link types
parentAnchorborrows. - Options: Hierarchy. Events:
expandedChangeon the events page; methods on the methods page. - Examples: the hierarchy example, the release plan and the milestones and placeholders example.
- API reference:
Hierarchy,MoveChildren,ExpandedKeys.