@jaeungkim/gantt-chart

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 valueGroup keyHeader 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, so collapsedIds={['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.

PropertyValue
tasks on the rownone
depth / aria-level0 / 1
row id`group:${key}`
height38px, the same as every other row
counted in aria-rowcountyes
counted in the PNG export's row countyes
keyboard cells1 — 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

SelectorWhat it is
.gantt-grid-row.groupthe header row in the task list
.gantt-task-row.groupthe header row in the timeline
.gantt-group-labelthe label, position: sticky; left: 0 so it survives horizontal scroll
.gantt-grid-group-countthe 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.

RuleEffect
orderingby startDate, not by input order; equal starts keep input order
fita task joins the first row whose last bar has ended, not the tightest one
touchinga task starting exactly when the previous one ends shares the row
overlapan overlapping task opens one extra row
reusea row is reused as soon as it is free
milestoneoccupies 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. groupBy and allowRowReorder are 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]
Rowiddepthorder on its tasks
1group:Design0
2spec+wires12 on both
3review13
4group:Backend0
5api15
6group:0
7cleanup17

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. groupBy is 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 collapsedIds like any other collapsed row.
  • No stable object identity. A task whose row number moved is handed on as a shallow copy, so turning groupBy on 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 the GanttGroupBy, GanttRow and GanttRowGroup types are exported — see Grouping types.

What the host app does itself:

  • Build the group: id string by hand when seeding or driving collapsedIds. Nothing in the package exports the prefix or a helper for it.
  • Handle rowTaskId: null in onTaskCreate if drawing on a header row is reachable.
  • Turn allowRowReorder off, or design around its absence, whenever groupBy is set.
  • Decide the order of bands, by deciding the order of the tasks.
  • Add any field the string form of groupBy reads to your own task type.

Next: The timeline covers scales, the visible range, and zooming.

On this page