Task list
The list of tasks that a classic gantt tool has on its left: a row for every row of the chart, at
the same height and place, indented by depth, with a toggle on the groups, the columns you choose
and a splitter on its edge. It is createTaskList for @ganttlane/dom and <TaskList> for
@ganttlane/react. The guide has all of it; the
task list beside the chart example draws one by hand from the same public
API, for when you want your own markup.
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';
// 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' },];import type { GanttOptions } from '@ganttlane/dom';
export const options = { // One column a week: the plan is a few screens wide, so the chart scrolls sideways. viewMode: 'week', // Every day is a working day, so a bar dropped on a weekend is no conflict to explain. calendar: { weekends: [] }, // A click on a bar selects it, and a click on empty space clears the selection: the list shows it. selection: { onClick: true, clearOnEmptyClick: true }, layout: { rowHeight: 36, barHeight: 22, headerRowHeight: 28 }, // Zebra stripes for each group with its children; the list draws the same. display: { rowStripes: 'group' }, timeline: { padding: '0d' }, scroll: { initial: 'first-task' },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';import './styles.css';
import { createGantt } from '@ganttlane/dom';import { createTaskList } from '@ganttlane/dom/task-list';
import { links, tasks } from './data';import { CHART_HEIGHT, columns, type HeightMode, HEIGHTS, THEMES, VIEW_MODES } from './helpers';import { options } from './options';
export function mount(container: HTMLElement): () => void { const controller = new AbortController(); const { signal } = controller;
const root = document.createElement('div'); const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); const plan = document.createElement('div'); plan.className = 'plan'; const side = document.createElement('div'); const chart = document.createElement('div'); chart.className = 'plan__chart'; plan.append(side, chart); const status = document.createElement('p'); status.textContent = 'Click a name or a bar. Drag a bar, and the Dates column follows.'; root.append(toolbar, plan, status); container.append(root);
const gantt = createGantt(chart, { tasks, links, ...options, layout: { ...options.layout, height: CHART_HEIGHT }, onSelectionChange: (keys) => { const [id] = keys; status.textContent = id === undefined ? 'Nothing selected.' : `Selected: ${String(id)}.`; }, }); // Everything else is kept in sync by the list: rows, sizes, selection, hover, focus, scrolling. const list = createTaskList(side, gantt, { columns });
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 = <T extends string>( label: string, values: readonly T[], selected: T, onChange: (value: T) => void, ): void => { const field = document.createElement('label'); const select = document.createElement('select'); for (const value of values) { // `new Option()` does not exist in happy-dom, where the examples are tested. const item = document.createElement('option'); item.value = item.textContent = value; item.selected = value === selected; select.append(item); } select.addEventListener( 'change', () => { const value = values.find((candidate) => candidate === select.value); if (value !== undefined) onChange(value); }, { signal }, ); field.append(`${label} `, select); toolbar.append(field); };
addButton('Expand all', () => gantt.expandAll()); addButton('Collapse all', () => gantt.collapseAll()); addSelect('View', VIEW_MODES, options.viewMode, (viewMode) => gantt.setViewMode(viewMode)); addSelect('Theme', THEMES, 'auto', (theme) => gantt.setOptions({ theme })); addSelect<HeightMode>('Height', HEIGHTS, 'fixed', (mode) => { plan.dataset.height = mode; gantt.setOptions({ layout: { height: mode === 'auto' ? 'auto' : CHART_HEIGHT } }); });
return () => { controller.abort(); list.destroy(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';import './styles.css';
import { TaskList, useGantt, useGanttState } from '@ganttlane/react';import { useState } from 'react';
import { links, tasks } from './data';import { CHART_HEIGHT, columns, type HeightMode, HEIGHTS, type Theme, THEMES, VIEW_MODES,} from './helpers';import { options } from './options';
export default function Example() { const [theme, setTheme] = useState<Theme>('auto'); const [height, setHeight] = useState<HeightMode>('fixed'); const [status, setStatus] = useState( 'Click a name or a bar. Drag a bar, and the Dates column follows.', );
// `viewMode` is a controlled prop in React: the select changes it through the instance, so the // chart starts from `defaultViewMode` and owns it. const { viewMode, layout, ...rest } = options;
const gantt = useGantt({ ...rest, defaultViewMode: viewMode, defaultTasks: tasks, defaultLinks: links, // In React the props are the truth: the height and the theme are state, passed from here. layout: { ...layout, height: height === 'auto' ? 'auto' : CHART_HEIGHT }, theme, onSelectionChange: ([id]) => { setStatus(id === undefined ? 'Nothing selected.' : `Selected: ${String(id)}.`); }, }); // Destructured, which keeps `react-hooks/refs` quiet about the ref callback. const { setContainer, slots } = gantt; const mode = useGanttState(gantt, (snapshot) => snapshot.viewMode.id);
return ( <div> <div role="toolbar"> <button type="button" onClick={() => gantt.expandAll()}> Expand all </button> <button type="button" onClick={() => gantt.collapseAll()}> Collapse all </button> <label> View{' '} <select value={mode} onChange={(event) => gantt.setViewMode(event.target.value)}> {VIEW_MODES.map((id) => ( <option key={id}>{id}</option> ))} </select> </label> <label> Theme{' '} <select value={theme} onChange={(event) => setTheme(THEMES.find((id) => id === event.target.value) ?? 'auto')} > {THEMES.map((id) => ( <option key={id}>{id}</option> ))} </select> </label> <label> Height{' '} <select value={height} onChange={(event) => setHeight(HEIGHTS.find((id) => id === event.target.value) ?? 'fixed') } > {HEIGHTS.map((id) => ( <option key={id}>{id}</option> ))} </select> </label> </div> <div className="plan" data-height={height}> {/* Everything is kept in sync by the list: rows, sizes, selection, hover, focus, scrolling. */} <TaskList gantt={gantt} columns={columns} /> <div ref={setContainer} className="plan__chart" /> {slots} </div> <p>{status}</p> </div> );}import type { GanttRow } from '@ganttlane/dom';
/** The view modes the toolbar offers. */export const VIEW_MODES = ['day', 'week', 'month'] as const;
/** The themes the toolbar offers. */export const THEMES = ['auto', 'light', 'dark'] as const;export type Theme = (typeof THEMES)[number];
/** What scrolls the rows: the chart itself, or one box around the list and the chart. */export const HEIGHTS = ['fixed', 'auto'] as const;export type HeightMode = (typeof HEIGHTS)[number];
/** The height of the chart when it scrolls itself; with `'auto'` the box around it does. */export const CHART_HEIGHT = 400;
const dates = new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short' });
/** * The dates of a row for the Dates column. The dates of a record are `Date`s with the task's own * wall clock, so they are read as local. A row without a bar has none. * * @param row - The row. * @returns `2 – 6 Mar`, or nothing. */export function datesOf({ record }: GanttRow): string { const { start, end, type } = record; if (start === undefined || end === undefined) return ''; return type === 'milestone' ? dates.format(start) : dates.formatRange(start, end);}
/** * The columns, the same for both renderers. Text only, so it needs no `render`, which would be a * node in vanilla and a `ReactNode` in React. */export const columns = [ { id: 'name', header: 'Task' }, { id: 'dates', header: 'Dates', width: 110, align: 'end', value: datesOf },] as const;/* * The list and the chart side by side. This is all the layout they need: the list has its own width * (and a splitter), and the chart takes the rest. */.plan { display: flex; border: 1px solid #d3d9e1;}
.plan__chart { flex: 1 1 0; /* Without this a flex item is as wide as its content, and the chart would widen the page. */ min-width: 0;}
/* `layout.height: 'auto'`: the chart is as tall as its rows, and this box scrolls both columns. */.plan[data-height='auto'] { height: 400px; overflow-y: auto;}Files
data.ts: thirty tasks in four groups, nested two levels deep, with a few links.options.ts: the sizes, the stripes, the view, andselectionso that a click on a bar selects it.helpers.ts: the columns, and the text of the Dates column.styles.css: the two side by side, and the box oflayout.height: 'auto'.vanilla.ts:createGantt,createTaskListand the toolbar.react.tsx: the same withuseGanttand<TaskList>.
Options
| Option | Value | What it does |
|---|---|---|
columns |
[{ id: 'name', … }, { id: 'dates', … }] |
The columns of the list; the first is the tree column, with the indent and the toggle |
selection.onClick |
true |
A click on a bar selects its task, and the list shows it |
selection.clearOnEmptyClick |
true |
A click on an empty part of the chart clears the selection |
layout.height |
400, or 'auto' with the Height select |
Who scrolls the rows: the chart, or the box around the list and the chart |
layout.rowHeight, headerRowHeight |
36, 28 |
The sizes the list reads back, so that its rows line up |
display.rowStripes |
'group' |
Zebra stripes for each group, the same in the list |
theme |
'auto' |
The Theme select; the list follows the chart |
What to try
- Scroll. Wheel over the names or over the chart: the other follows.
- Click a name far down the list (Release): the chart scrolls sideways to its bar and selects it, and nothing moves if the bar is already in view. Click a bar: its name is selected. Click empty space in the chart: the selection is cleared.
- Open and shut. Click the chevron of Build, or press Left with the focus on it.
- Drag a bar: its dates change when you drop it, and so do the dates of what it pushed.
- Point at a row, in the list or in the chart: both light up.
- Drag the splitter on the edge of the list, or focus it and press Left and Right.
- Use the keyboard. Tab to the list, then Up, Down, Page Up, Page Down, Home, End, Left and Right. Enter selects the row and scrolls the chart to its bar.
- Change the Height. With
fixedthe chart scrolls and the list follows it, which during a fast fling can be a frame behind. Withautoone box scrolls both, and they cannot differ.
Notes
- The row hover is on here because a list is attached. A chart without one lights no row, unless
you set
display.rowHoveror--gantt-row-hover-coloryourself. - A list draws what the chart draws, the rows in view and a few more, so it stays fast with thousands of tasks.
- Dates follow a commit, not the drag in progress: the record of a task changes when the change is committed.