Grouping and swimlanes
`groupBy`, group header rows, and lanes
Forty tasks from three teams arrive as one flat list. Reading it means scanning the name column
for a prefix somebody agreed on in a meeting. groupBy splits that list into bands with a header
row each, and the task field lane puts tasks that never overlap on a single row.
The two are independent. Grouping decides which band a task sits in; lanes decide how many rows a band needs.
Grouping by a field
groupBy takes either a field name or an accessor.
import { ReactGanttChart, type Task } from '@jaeungkim/gantt-chart';
// The string form reads the field off the task with a bare index, so widen your own
// task type rather than reaching for `any`.
type ProjectTask = Task & { team?: string | null };
const tasks: ProjectTask[] = [
{ id: 'spec', name: 'Spec', team: 'Design', parentId: null, sequence: '1',
startDate: '2026-03-02T00:00:00Z', endDate: '2026-03-06T00:00:00Z' },
{ id: 'api', name: 'API', team: 'Backend', parentId: null, sequence: '2',
startDate: '2026-03-02T00:00:00Z', endDate: '2026-03-13T00:00:00Z' },
];
export function TeamChart() {
return <ReactGanttChart tasks={tasks} groupBy="team" height={480} />;
}The accessor form receives the transformed task and returns the group value:
import {
ReactGanttChart,
type Task,
type TaskTransformed,
} from '@jaeungkim/gantt-chart';
function byMonth(task: TaskTransformed): string | null {
if (task.type === 'milestone') return null; // milestones land in Ungrouped
return task.startDate.slice(0, 7); // "2026-03"
}
export function MonthChart({ tasks }: { tasks: Task[] }) {
return <ReactGanttChart tasks={tasks} groupBy={byMonth} height={480} />;
}The full signature is string | ((task: TaskTransformed) => string | null | undefined). The
null and undefined in that return type are load-bearing: they are how an accessor says "this
task has no group". The argument is a TaskTransformed, not a raw Task, so the accessor also
sees depth, order, isSummary, bar geometry, and the scheduling metrics when the matching
prop is on — see Task.
Every value that is not null, undefined or "" is passed through String(value), and that
string is both the group key and the header label. There is no separate label callback.
Group order is first appearance in the task array, which is sequence order — see
Task data. There is no prop that sorts groups, and no prop that declares a group
that holds no task. A group exists because a task landed in it.
The string form is an unchecked index read. groupBy="owner" on tasks that carry no owner does
not throw; every task falls into one Ungrouped band.
The Ungrouped bucket
Exactly three values put a task in the Ungrouped bucket: null, undefined, and the empty
string. Every other value becomes a group of its own.
| Field or accessor value | Group key | Header label |
|---|---|---|
"Design" | "Design" | Design |
null | "" | ungroupedLabel |
undefined | "" | ungroupedLabel |
"" | "" | ungroupedLabel |
0 | "0" | 0 |
false | "false" | false |
NaN | "NaN" | NaN |
The three falsy values in the lower half are not special-cased. groupBy="progress" on a task
with progress: 0 produces a band whose header reads 0, next to a band whose header reads 40.
ungroupedLabel renames that band. Its default is "Ungrouped", and the default is applied
inside the row builder rather than at the prop.
<ReactGanttChart tasks={tasks} groupBy="team" ungroupedLabel="No team" />Passing ungroupedLabel="" therefore gives an empty header, not the default. An empty string is a
supplied value, so the fallback never fires. The header row still renders, at full height, with
its count badge and nothing else.
[!IMPORTANT] The collapse id of the Ungrouped band is the literal string
"group:"— the prefix with an empty key after it. It is built from the group key, and the key is"". The label plays no part, socollapsedIds={['group:Ungrouped']}is a silent no-op.
Group header rows
A group header is a real row in the same list as the task rows. It is not a decoration drawn between rows.
| Property | Value |
|---|---|
| tasks on the row | none |
depth / aria-level | 0 / 1 |
| row id | `group:${key}` |
| height | 38px, the same as every other row |
counted in aria-rowcount | yes |
| counted in the PNG export's row count | yes |
| keyboard cells | 1 — the whole header is one cell |
The number in the badge is how many tasks the group holds among the tasks the chart was given. That is the visible set, so collapsing a summary row inside a group lowers its badge. Collapsing the group itself does not, because the count is taken before the collapse is applied.
Collapsing a group
Group headers use the same collapse state as summary rows — the collapsedIds /
defaultCollapsedIds / onCollapsedChange triple documented in
Task list and hierarchy. One flat list of strings holds both kinds of id. Task ids
are ignored by the grouping pass, and group: ids are ignored by the tree filter, so the two
never collide.
// Start with the Backend band and the Ungrouped band closed.
<ReactGanttChart
tasks={tasks}
groupBy="team"
defaultCollapsedIds={['group:Backend', 'group:']}
/>A collapsed group keeps its header and drops its member rows. The key is used verbatim, with no
escaping, so a group value containing : or + appears as-is in the id.
Collapsing a group works whether hierarchy is on or off; the two collapse mechanisms are
separate code paths.
With showTaskList off there is no expander button on a header row. The timeline pane renders the
label and the count only, so the band can be collapsed from the keyboard but not from the pointer
— see Keyboard and screen readers.
Styling a header
| Selector | What it is |
|---|---|
.gantt-grid-row.group | the header row in the task list |
.gantt-task-row.group | the header row in the timeline |
.gantt-group-label | the label, position: sticky; left: 0 so it survives horizontal scroll |
.gantt-grid-group-count | the count pill, also used for the lane +N badge |
The colors come from the --gantt-* custom properties in Theming.
Grouping and the parentId hierarchy
Grouping occupies the outermost level. Headers are always at depth 0, task rows inside a band sit
one level deeper, and every header is a sibling of every other header. There is no nesting: a
single groupBy value, never a list.
Because grouped task rows sit at depth 1 or deeper, turning on groupBy indents every task row by
16px even when hierarchy is off.
With hierarchy on, a task's group is read off its root ancestor, not off the task. A subtree
is therefore never split across two bands, however much the field differs between parent and
child. A child named QA under a parent named Dev, grouped by name, lands under Dev.
Levels shift down by one: header at aria-level 1, a root task at 2, its child at 3.
With hierarchy off there is no root lookup at all. The group comes off the task itself, and
parentId is ignored. The root-ancestor rule and the hierarchy prop are the same switch — see
Task list and hierarchy.
Lanes
lane is a field on the task, not a chart prop. Tasks that share a lane string inside the same
group are drawn side by side on one row.
import { ReactGanttChart, type Task } from '@jaeungkim/gantt-chart';
const tasks: Task[] = [
{ id: 'spec', name: 'Spec', lane: 'ana', parentId: null, sequence: '1',
startDate: '2026-03-02T00:00:00Z', endDate: '2026-03-06T00:00:00Z' },
{ id: 'wires', name: 'Wireframes', lane: 'ana', parentId: null, sequence: '2',
startDate: '2026-03-09T00:00:00Z', endDate: '2026-03-12T00:00:00Z' },
];
<ReactGanttChart tasks={tasks} height={480} />;A falsy lane is no lane. lane: "" gets the task a row of its own, the same as omitting the
field.
How lane packing works
Lane members are packed greedily, first fit, in start-date order.
| Rule | Effect |
|---|---|
| ordering | by startDate, not by input order; equal starts keep input order |
| fit | a task joins the first row whose last bar has ended, not the tightest one |
| touching | a task starting exactly when the previous one ends shares the row |
| overlap | an overlapping task opens one extra row |
| reuse | a row is reused as soon as it is free |
| milestone | occupies its startDate only; its endDate is ignored |
Packing runs per group. Two tasks with the same lane string in different groups never share a row. The packed rows are inserted at the position of the lane's first-appearing member; later members contribute nothing at their own positions.
In the task list, a lane row shows the columns of its earliest-starting task — tasks[0]
after packing, not the first one in the input array — plus a +N badge in the first column where
N is the number of other tasks on the row.
What a lane row costs
Every task on a lane row reports the same order. order is what positions dependency arrows
vertically, so a link drawn between two lane-mates renders as one horizontal line — see
Dependencies.
Drawing a new task on a lane row reports only the earliest-starting task's id as rowTaskId; the
other members are unreachable that way. Drawing on a group header row is not blocked, and reports
rowTaskId: null — see Editing tasks.
[!WARNING] Row reordering turns itself off, silently and chart-wide, as soon as one group header exists or one lane row carries two tasks. A row id is a task id only while every row holds exactly one task. There is no warning and no callback; the rows stop being draggable.
groupByandallowRowReorderare effectively incompatible — see Reordering rows.
An unparseable endDate inside a lane poisons the row that task opened. The fit test is
rowEnd <= start, and every comparison against NaN is false, so that row is never offered to a
later task again. Later lane members fall through to the next free row, and open a new one when
there is none.
A worked example
Five tasks, grouped by team, with three of them sharing the lane ana. hierarchy is off.
import { ReactGanttChart, type Task } from '@jaeungkim/gantt-chart';
type ProjectTask = Task & { team?: string | null };
const tasks: ProjectTask[] = [
{ id: 'spec', name: 'Spec', team: 'Design', lane: 'ana', parentId: null, sequence: '1',
startDate: '2026-03-02T00:00:00Z', endDate: '2026-03-06T00:00:00Z' },
{ id: 'wires', name: 'Wireframes', team: 'Design', lane: 'ana', parentId: null, sequence: '2',
startDate: '2026-03-09T00:00:00Z', endDate: '2026-03-12T00:00:00Z' },
{ id: 'review', name: 'Review', team: 'Design', lane: 'ana', parentId: null, sequence: '3',
startDate: '2026-03-04T00:00:00Z', endDate: '2026-03-05T00:00:00Z' },
{ id: 'api', name: 'API', team: 'Backend', parentId: null, sequence: '4',
startDate: '2026-03-02T00:00:00Z', endDate: '2026-03-13T00:00:00Z' },
{ id: 'cleanup', name: 'Cleanup', team: null, parentId: null, sequence: '5',
startDate: '2026-03-16T00:00:00Z', endDate: '2026-03-18T00:00:00Z' },
];
<ReactGanttChart tasks={tasks} groupBy="team" height={480} />;Seven rows come out of five tasks:
task list timeline
Mar 2 Mar 9 Mar 16
────────────────────────────────────────────────────────────
v Design 3
Spec +1 [ Spec ] [ Wires ]
Review [Rev]
v Backend 1
API [ API ]
v Ungrouped 1
Cleanup [Cleanup]| Row | id | depth | order on its tasks |
|---|---|---|---|
| 1 | group:Design | 0 | — |
| 2 | spec+wires | 1 | 2 on both |
| 3 | review | 1 | 3 |
| 4 | group:Backend | 0 | — |
| 5 | api | 1 | 5 |
| 6 | group: | 0 | — |
| 7 | cleanup | 1 | 7 |
Three things in that table are worth pausing on. spec and wires share row 2 because spec
ends on Mar 6 and wires starts on Mar 9; review overlaps spec, so it opens row 3. Header
rows consume order numbers, which is why api reports 5 and not 3. The Ungrouped header's
id is group:, while its label is Ungrouped.
Limits
Grouping rewrites order and nothing else. It does not touch dates, bar geometry, or
originalOrder.
What this area does not do:
- No sorting. Group order is first appearance in the task array. Reordering the bands means reordering the tasks.
- No empty groups. A band with no task does not render. There is no "always show these groups" option.
- No nesting.
groupByis one value. Headers are always at depth 0. - No cross-group lanes. Packing runs per band, so one lane string can produce rows in several bands.
- No validation. An unknown field name yields a single Ungrouped band rather than an error.
- No persistence. Collapsed group ids live in
collapsedIdslike any other collapsed row. - No stable object identity. A task whose row number moved is handed on as a shallow copy, so
turning
groupByon replaces almost every task reference the chart passes to renderers and callbacks. Ungrouped, lane-free charts hand back the objects they were given. - One task per lane row in the task list. The columns and the keyboard cells of a lane row act on its earliest-starting task; the other members are reachable only through their bars — see Keyboard and screen readers.
- No exported helpers. The row builder, the lane packer, and the
group:prefix are internal. Only theGanttGroupBy,GanttRowandGanttRowGrouptypes are exported — see Grouping types.
What the host app does itself:
- Build the
group:id string by hand when seeding or drivingcollapsedIds. Nothing in the package exports the prefix or a helper for it. - Handle
rowTaskId: nullinonTaskCreateif drawing on a header row is reachable. - Turn
allowRowReorderoff, or design around its absence, whenevergroupByis set. - Decide the order of bands, by deciding the order of the tasks.
- Add any field the string form of
groupByreads to your own task type.
Next: The timeline covers scales, the visible range, and zooming.