Skip to content

Task list beside the chart

A classic gantt tool has a grid on the left: the task names, indented by their depth, with a column of dates, and the bars to the right of it on the same rows. The library has one, the task list. This example draws its own instead, for when you want your own markup: the chart tells you where its rows are, and a list you draw yourself lines up with them. This one has

  • a label for every visible row, at the row’s height and place, indented by depth, with the dates as a second column, under a header as tall as the chart’s;
  • toggles on the groups (expand and collapse), and keys: Up and Down move, Left and Right shut and open a group, Enter selects and scrolls to the bar (role="tree", with aria-expanded and aria-level);
  • a click on a name that selects the task and scrolls the chart to its bar (select, scrollToTask), and a click on a bar that selects its row in the list (onTaskClick);
  • hover both ways: a name lights its row in the chart, and a bar (or its row) lights its name;
  • dates that follow a drag (onTasksChange), and rows that still line up after a change of the row height, the view mode, the theme, or what is collapsed.

The list is plain markup that reads the chart’s public API. Nothing here is part of the library.

Terminal window
pnpm add @ganttlane/dom # vanilla
pnpm add @ganttlane/react react react-dom # React
View on GitHub
data.ts
import type { Link, Task } from '@ganttlane/core';
// A plan of thirty tasks in four groups (Plan, Build, Test, Launch), nested two levels deep: a
// group holds tasks and sub-groups, a sub-group holds tasks. `parentId` makes the tree, and the
// rows are the tasks in pre-order (a group, then what is in it). That is more rows than the demo
// shows at once, so the list scrolls. 2026-03-02 is a Monday; a task with an `end` ends on that
// day (inclusive).
//
// A group has dates of its own that cover its children, and `followParent` ties a child to its
// group: drag a group and the children follow, and the list shows their new dates.
export const tasks: Task[] = [
// ---- Plan ----
{ id: 'plan', name: 'Plan', start: '2026-03-02', end: '2026-03-20', progress: 100 },
{
id: 'research',
name: 'Research',
parentId: 'plan',
start: '2026-03-02',
end: '2026-03-06',
progress: 100,
followParent: true,
},
{
id: 'scope',
name: 'Scope',
parentId: 'plan',
start: '2026-03-09',
end: '2026-03-10',
progress: 100,
followParent: true,
},
{
id: 'architecture',
name: 'Architecture',
parentId: 'plan',
start: '2026-03-11',
end: '2026-03-20',
progress: 100,
followParent: true,
},
{
id: 'system-design',
name: 'System design',
parentId: 'architecture',
start: '2026-03-11',
end: '2026-03-17',
progress: 100,
followParent: true,
},
{
id: 'data-model',
name: 'Data model',
parentId: 'architecture',
start: '2026-03-16',
end: '2026-03-19',
progress: 100,
followParent: true,
},
{
id: 'api-contract',
name: 'API contract',
parentId: 'architecture',
start: '2026-03-18',
end: '2026-03-20',
progress: 100,
followParent: true,
},
// ---- Build ----
{ id: 'build', name: 'Build', start: '2026-03-23', end: '2026-05-01', progress: 45 },
{
id: 'backend',
name: 'Backend',
parentId: 'build',
start: '2026-03-23',
end: '2026-04-17',
progress: 80,
followParent: true,
},
{
id: 'data-layer',
name: 'Data layer',
parentId: 'backend',
start: '2026-03-23',
end: '2026-04-03',
progress: 100,
followParent: true,
},
{
id: 'services',
name: 'Services',
parentId: 'backend',
start: '2026-04-06',
end: '2026-04-14',
progress: 75,
followParent: true,
},
{
id: 'endpoints',
name: 'Endpoints',
parentId: 'backend',
start: '2026-04-15',
end: '2026-04-17',
progress: 40,
followParent: true,
},
{
id: 'frontend',
name: 'Frontend',
parentId: 'build',
start: '2026-03-30',
end: '2026-04-24',
progress: 50,
followParent: true,
},
{
id: 'components',
name: 'Components',
parentId: 'frontend',
start: '2026-03-30',
end: '2026-04-10',
progress: 100,
followParent: true,
},
{
id: 'screens',
name: 'Screens',
parentId: 'frontend',
start: '2026-04-08',
end: '2026-04-22',
progress: 50,
followParent: true,
},
{
id: 'state-handling',
name: 'State handling',
parentId: 'frontend',
start: '2026-04-15',
end: '2026-04-24',
progress: 10,
followParent: true,
},
{
id: 'integration',
name: 'Integration',
parentId: 'build',
start: '2026-04-20',
end: '2026-05-01',
progress: 0,
followParent: true,
},
// ---- Test ----
{ id: 'test', name: 'Test', start: '2026-04-27', end: '2026-05-15' },
{
id: 'unit-tests',
name: 'Unit tests',
parentId: 'test',
start: '2026-04-27',
end: '2026-05-06',
followParent: true,
},
{
id: 'end-to-end-tests',
name: 'End-to-end tests',
parentId: 'test',
start: '2026-05-04',
end: '2026-05-12',
followParent: true,
},
{
id: 'performance',
name: 'Performance',
parentId: 'test',
start: '2026-05-06',
end: '2026-05-15',
followParent: true,
},
{
id: 'load-test',
name: 'Load test',
parentId: 'performance',
start: '2026-05-06',
end: '2026-05-12',
followParent: true,
},
{
id: 'profiling',
name: 'Profiling',
parentId: 'performance',
start: '2026-05-11',
end: '2026-05-15',
followParent: true,
},
{
id: 'bug-fixes',
name: 'Bug fixes',
parentId: 'test',
start: '2026-05-11',
end: '2026-05-15',
followParent: true,
},
// ---- Launch ----
{ id: 'launch', name: 'Launch', start: '2026-05-18', end: '2026-05-29' },
{
id: 'docs',
name: 'Docs',
parentId: 'launch',
start: '2026-05-18',
end: '2026-05-22',
followParent: true,
},
{
id: 'guides',
name: 'Guides',
parentId: 'docs',
start: '2026-05-18',
end: '2026-05-21',
followParent: true,
},
{
id: 'api-reference',
name: 'API reference',
parentId: 'docs',
start: '2026-05-19',
end: '2026-05-22',
followParent: true,
},
{
id: 'release-candidate',
name: 'Release candidate',
parentId: 'launch',
start: '2026-05-25',
end: '2026-05-27',
followParent: true,
},
{
id: 'release',
name: 'Release',
type: 'milestone',
parentId: 'launch',
start: '2026-05-29',
followParent: true,
},
];
export const links: Link[] = [
{ source: 'research', target: 'scope' },
{ source: 'scope', target: 'system-design' },
{ source: 'api-contract', target: 'data-layer' },
{ source: 'data-layer', target: 'services' },
{ source: 'services', target: 'endpoints' },
{ source: 'endpoints', target: 'integration' },
{ source: 'integration', target: 'end-to-end-tests' },
{ source: 'bug-fixes', target: 'release-candidate' },
{ source: 'release-candidate', target: 'release' },
];

Files

  • data.ts: thirty tasks in four groups, nested two levels deep, with a few links.
  • options.ts: layout (the sizes, and height: 'auto'), the stripes and the view.
  • helpers.ts: the list’s rows from getRows(), the dates text, and what each key does (treeAction).
  • styles.css: the list’s own styles, in the sizes the chart reports.
  • vanilla.ts: the markup, the list drawn from gantt.core.getSnapshot() and kept by gantt.core.subscribe, the events and the toolbar.
  • react.tsx: the same with useGantt and useGanttState.

Options

Option Value What it does
layout.height 'auto' The chart is as tall as its rows and does not scroll vertically: the box around the list and the chart does, header stuck
layout.rowHeight, barHeight, headerRowHeight 36, 22, 28 The sizes the list reads back (snapshot.layout.rows); the Row height select changes them with setOptions({ layout })
display.rowStripes 'group' Zebra stripes for each group; the list draws the same bands from Row.groupStripe
viewMode 'week' The scale (React: defaultViewMode); the View select calls setViewMode
theme 'auto' The Theme select; the list follows the data-theme of the chart’s root (see Notes)
calendar.weekends [] Every day is a working day, so a bar dropped on a weekend is no conflict to explain
scroll.initial 'first-task' Opens on the plan
onTaskClick a function (in the example) A click on a bar selects its task: select([id])
onTasksChange a function (in the example) One ChangeSet per drag; the status line says what moved, and the list shows the new dates
onExpandedChange a function (in the example) The open groups after expand, collapse, expandAll and collapseAll, from the list, a button or code

What the list reads

From the chart Used for
gantt.getRows() The visible rows in order: name, depth, hasChildren, isExpanded, the stripes, record
gantt.getLayoutMetrics() rowHeight and headerHeight, which become --task-list-row-height and -header-height
gantt.getRowMetrics(id) Where a row lies, for the band that lights the hovered row across the chart
snapshot.selection.tasks The selected rows (aria-selected), wherever the selection was made
snapshot.hoveredRow The task whose bar, or whose row, the pointer is on in the chart
gantt.core.subscribe(listener) Called after every change of the snapshot: how the list hears of new rows, names and dates

What to try

  • Scroll. Wheel over the names or over the chart: one box scrolls, and the rows stay together. The headers stay at the top, and so does the list when the chart scrolls sideways.
  • Click a name far down the list (Release): the chart scrolls sideways to its bar and selects it; the box does not move. Click a bar: its name is selected.
  • Open and shut. Click the chevron of Build, or press Left with the focus on it: its nine tasks leave the list and the chart together. Collapse all and Expand all are buttons of the page, and the list follows them the same way.
  • Drag a bar, say Research, to the right: its dates change, and so do the dates of what it pushed (the links).
  • Change the Row height, the View or the Theme: the rows still line up.
  • Use the keyboard. Tab to the list, then Up, Down, Home, End, Left and Right. Enter selects the row and scrolls the chart to its bar. A row you move to scrolls into view below the header.

Notes

  • One box scrolls both, and why. With layout.height: 'auto' the chart is as tall as its rows and the nearest ancestor that scrolls (here .task-list-scroller) scrolls it, while the header of the chart sticks to the top of that box. The list is a second column in the same box with a sticky header of its own, so wheel, touch, scrollbar and keyboard move both with no code and nothing to keep in step. The other design is a chart of a fixed height with its own scroller, and a list that is synced to it: the chart tells the list where it is (on('scroll'), y), which works, but there is no call to scroll the chart from the list, so the list would have to write the scrollTop of the chart’s .gantt__scroller by itself, and each side would move the other from a scroll event, which arrives after the browser has already moved the first.
  • The trade-offs of one box. The sideways scrollbar belongs to the chart’s scroller, which is as tall as its rows, so it is under the last row (invisible with overlay scrollbars; sideways wheel and touch work anywhere over the chart, and a click on a name scrolls to the bar). The chart draws the rows in view and five more on each side after a scroll, as a chart of a fixed height does, so a fling much faster than that can show an empty chart row for a frame. And the box is yours: it needs a height, and nothing between it and the chart may clip (overflow: hidden).
  • Why alignY: 'nearest'. scrollToTask scrolls on both axes, and a click on a visible name should not move the row away from the pointer. 'nearest' leaves a row that is in view where it is.
  • When the list renders. subscribe runs for every change of the snapshot: a scroll frame, a hover and a drag preview are each one. getRows() and getLayoutMetrics() keep their identity until they change, so the list compares them and rebuilds only then. What is left for every snapshot is a few attributes. It draws every row: for thousands, draw only the rows of gantt.getRowWindow() (what the chart itself draws), as the built-in list does.
  • Colours. The chart declares its colours on .gantt, which a list beside it cannot inherit. This list has a small palette of its own (light-dark(), the values of the default theme) and takes its color-scheme from the data-theme of the chart’s root (:has()): the theme select, the site’s toggle and prefers-color-scheme ('auto') all reach the list that way. The class gantt-scope is the other way: it gives an element every --gantt-* token.
  • Hover. A chart on its own lights no row, so when the pointer is on a name the example lays a band (.task-list__band) over the chart’s row, at headerHeight + getRowMetrics(id).y. setHoveredRow with display.rowHover is the other way, which the built-in list uses. The other way round, snapshot.hoveredRow names the task of the pointer’s row even where there is no bar (onTaskHoverChange names bars only), so the whole row of the chart lights its name.
  • React. useGantt and useGanttState, not <Gantt ref>. The list is drawn from the chart’s snapshot, so it needs the chart while it renders: useGantt returns it at once (the core exists during the render, so the list also renders on a server), while a ref is null until the chart is committed, which leaves the first render of the list empty. And setContainer puts the chart in the box beside the list, in the column you want, without the div that <Gantt> adds. useGanttState renders the list again only when the slice it reads changes: not while the chart scrolls. viewMode is a controlled prop, so the select calls setViewMode on the instance and the chart starts from defaultViewMode; the row height and the theme are state, passed to layout and theme, because in React the props are the truth.