Task colours and classes
There are two ways to say what a task looks like, and the chart supports both:
- A class,
task.classNamefor the bar andtask.rowClassNamefor its row. The class is a hook for your stylesheet: the example’sstyles.cssturns five status classes into five bar colours, and two of them into a tint of the whole row. Themes, hover states and print styles all stay in CSS. - A colour in the data,
task.colorandtask.progressColor. The renderer writes them as the custom properties--gantt-bar-colorand--gantt-bar-progress-coloron that one bar. Nothing to write in CSS; the colour is a value you compute.
The plan below is styled the first way. The Style from select swaps the tasks for the same plan
with colours in the data. Row stripes is display.rowStripes.
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';
/** What `task.data` holds: the status of a task. */export interface Meta { status: 'done' | 'active' | 'late' | 'blocked' | 'review';}
// 2026-03-02 is a Monday. Three phases with children, one status each, styled by CLASS: the// stylesheet (`styles.css`) turns `className` into the colour of the bar and `rowClassName` into a// tint of the whole row. A phase is a task like the others, with children.export const tasks: Task<Meta>[] = [ // ---- Discovery: finished ---- { id: 'discovery', name: 'Discovery', start: '2026-03-02', end: '2026-03-06', progress: 100, className: 'is-phase is-done', data: { status: 'done' }, }, { id: 'interviews', name: 'Interviews', parentId: 'discovery', start: '2026-03-02', end: '2026-03-03', progress: 100, className: 'is-done', data: { status: 'done' }, }, { id: 'requirements', name: 'Requirements', parentId: 'discovery', start: '2026-03-04', end: '2026-03-06', progress: 100, className: 'is-done', data: { status: 'done' }, },
// ---- Build: one task is late, one is blocked; their rows are tinted ---- { id: 'build', name: 'Build', start: '2026-03-09', end: '2026-03-24', progress: 45, className: 'is-phase is-active', data: { status: 'active' }, }, { id: 'backend', name: 'Backend', parentId: 'build', start: '2026-03-09', end: '2026-03-18', progress: 70, className: 'is-active', data: { status: 'active' }, }, { id: 'frontend', name: 'Frontend', parentId: 'build', start: '2026-03-11', end: '2026-03-24', progress: 35, className: 'is-late', rowClassName: 'row-late', data: { status: 'late' }, }, { id: 'integrations', name: 'Integrations', parentId: 'build', start: '2026-03-19', end: '2026-03-24', progress: 0, className: 'is-blocked', rowClassName: 'row-blocked', data: { status: 'blocked' }, },
// ---- Release ---- { id: 'release', name: 'Release', start: '2026-03-25', end: '2026-03-27', progress: 0, className: 'is-phase is-review', data: { status: 'review' }, }, { id: 'qa', name: 'QA', parentId: 'release', start: '2026-03-25', end: '2026-03-26', progress: 50, // Colour set on the task itself wins over the class: this one bar is teal whatever its // status says, and its progress is a darker teal of its own. color: '#0f9d8a', progressColor: '#0a6b5d', className: 'is-active', data: { status: 'active' }, }, { id: 'launch', name: 'Launch', parentId: 'release', type: 'milestone', start: '2026-03-27', className: 'is-review', data: { status: 'review' }, },];
export const links: Link[] = [ { source: 'interviews', target: 'requirements' }, { source: 'requirements', target: 'backend' }, { source: 'backend', target: 'integrations' }, { source: 'integrations', target: 'qa' }, { source: 'qa', target: 'launch' },];
/** The colours of the statuses when they come from the data, not from a stylesheet. */const COLOURS: Record<Meta['status'], { color: string; progressColor: string }> = { done: { color: '#2f9e44', progressColor: '#1c6b2c' }, active: { color: '#3f6fd1', progressColor: '#264a96' }, late: { color: '#d9480f', progressColor: '#9c3207' }, blocked: { color: '#6b7280', progressColor: '#3f4651' }, review: { color: '#7c3aed', progressColor: '#5421ab' },};
/** * The same plan styled from the data instead: every task carries its own `color` and * `progressColor` (the renderer writes them as `--gantt-bar-color` and `--gantt-bar-progress-color` * on its bar), and no class. A colour is a value, so there is no theme to follow and no rule to * write, but there is also no row tint: `rowClassName` is the only way to style a row. (QA keeps * the teal it sets for itself.) */export const coloured: Task<Meta>[] = tasks.map(({ className, rowClassName, ...task }) => ({ ...COLOURS[task.data?.status ?? 'active'], ...task,}));import type { GanttOptions } from '@ganttlane/dom';
import type { Meta } from './data';
export const options = { // Zebra stripes: 'row' (every other row), 'group' (every other parent with its children), or // false (none, the default). A task's `rowClassName` paints over a stripe. display: { rowStripes: 'group' }, // Shorter rows than the default (48 px), so that the whole plan is on the screen. layout: { rowHeight: 34, barHeight: 20 }, viewMode: 'week', scroll: { initial: 'first-task' },} satisfies Partial<GanttOptions<Meta>>;import '@ganttlane/dom/styles.css';import './styles.css';
import { createGantt, type RowStripesOption } from '@ganttlane/dom';
import { coloured, links, type Meta, tasks } from './data';import { options } from './options';
const STATUSES = ['done', 'active', 'late', 'blocked', 'review'] as const;const STRIPES: Record<string, RowStripesOption> = { group: 'group', row: 'row', off: false };
export function mount(container: HTMLElement): () => void { // `.statuses` holds the palette of `styles.css` and keeps its rules to this chart. const root = document.createElement('div'); root.className = 'statuses'; const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); const legend = document.createElement('ul'); legend.className = 'legend'; for (const status of STATUSES) { const item = document.createElement('li'); item.dataset.status = status; item.textContent = status; legend.append(item); } const chart = document.createElement('div'); chart.style.height = '420px'; const status = document.createElement('p'); root.append(toolbar, legend, chart, status); container.append(root);
const gantt = createGantt<Meta>(chart, { tasks, links, ...options });
const controller = new AbortController(); const addSelect = ( label: string, choices: readonly (readonly [value: string, text: string])[], onChange: (value: string) => void, ): void => { const wrapper = document.createElement('label'); wrapper.append(`${label} `); const select = document.createElement('select'); for (const [value, text] of choices) { const option = document.createElement('option'); option.value = value; option.textContent = text; select.append(option); } select.addEventListener('change', () => onChange(select.value), { signal: controller.signal }); wrapper.append(select); toolbar.append(wrapper); };
const describe = (byColour: boolean): string => byColour ? 'Styled from the data: every task has a color and a progressColor. No class, so no row tint.' : 'Styled by classes: className colours the bar, rowClassName tints the row (see styles.css).'; status.textContent = describe(false);
addSelect( 'Style from', [ ['classes', 'classes (a stylesheet)'], ['colours', 'colours in the data'], ], (value) => { // The tasks are the only thing that changes: the same plan, styled the other way. gantt.setTasks(value === 'colours' ? coloured : tasks); status.textContent = describe(value === 'colours'); }, ); addSelect( 'Row stripes', [ ['group', "'group'"], ['row', "'row'"], ['off', 'false'], ], // `display` is merged field by field, so this changes the stripes and nothing else. (value) => gantt.setOptions({ display: { rowStripes: STRIPES[value] ?? false } }), );
return () => { controller.abort(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';import './styles.css';
import { Gantt, type GanttHandle } from '@ganttlane/react';import { useRef, useState } from 'react';
import { coloured, links, type Meta, tasks } from './data';import { options } from './options';
const STATUSES = ['done', 'active', 'late', 'blocked', 'review'];const STRIPES = ['group', 'row', 'off'] as const;type Stripes = (typeof STRIPES)[number];
export default function Example() { const chart = useRef<GanttHandle<Meta>>(null); const [byColour, setByColour] = useState(false); const [stripes, setStripes] = useState<Stripes>('group');
// In React `tasks`, `links` and `viewMode` are controlled props, and `defaultTasks`, // `defaultLinks` and `defaultViewMode` are where an uncontrolled chart starts (there is no // `dataMode` prop). `display` and `layout` are plain props, merged field by field. const { viewMode, ...rest } = options;
return ( // `.statuses` holds the palette of `styles.css` and keeps its rules to this chart. <div className="statuses"> <div role="toolbar"> <label> Style from{' '} <select value={byColour ? 'colours' : 'classes'} onChange={(event) => { const next = event.target.value === 'colours'; setByColour(next); // The tasks are the only thing that changes: the same plan, styled the other way. chart.current?.setTasks(next ? coloured : tasks); }} > <option value="classes">classes (a stylesheet)</option> <option value="colours">colours in the data</option> </select> </label> <label> Row stripes{' '} <select value={stripes} onChange={(event) => { const next = STRIPES.find((id) => id === event.target.value); if (next) setStripes(next); }} > <option value="group">'group'</option> <option value="row">'row'</option> <option value="off">false</option> </select> </label> </div> <ul className="legend"> {STATUSES.map((id) => ( <li key={id} data-status={id}> {id} </li> ))} </ul> <Gantt<Meta> ref={chart} style={{ height: 420 }} defaultTasks={tasks} defaultLinks={links} defaultViewMode={viewMode} {...rest} display={{ rowStripes: stripes === 'off' ? false : stripes }} /> <p> {byColour ? 'Styled from the data: every task has a color and a progressColor. No class, so no row tint.' : 'Styled by classes: className colours the bar, rowClassName tints the row (see styles.css).'} </p> </div> );}/* * Statuses as classes. `task.className` is added to the bar's own element (`g.gantt__bar`) and * `task.rowClassName` to its row (`rect.gantt__row`). The library's CSS is in `@layer gantt`, so * these rules, which are not, win without `!important`. * * A bar takes its colour from `--gantt-bar-color` and the colour of its progress is derived from * that one (the library mixes it towards black), so a status needs one custom property, not two * rules. `.statuses` is a class on the example's wrapper: it holds the palette and keeps the * rules away from every other chart on the page. */
.statuses { --status-done: #2f9e44; --status-active: #3f6fd1; --status-late: #d9480f; --status-blocked: #6b7280; --status-review: #7c3aed;}
.statuses .gantt__bar.is-done { --gantt-bar-color: var(--status-done);}
.statuses .gantt__bar.is-active { --gantt-bar-color: var(--status-active);}
.statuses .gantt__bar.is-late { --gantt-bar-color: var(--status-late);}
.statuses .gantt__bar.is-blocked { --gantt-bar-color: var(--status-blocked);}
.statuses .gantt__bar.is-review { --gantt-bar-color: var(--status-review);}
/* A phase: the same colour, with a much darker progress, so that it reads as a summary bar. */.statuses .gantt__bar.is-phase { --gantt-bar-progress-shade: 55%;}
/* Rows: a tint over the whole row, with an alpha so that the stripes and grid show through. */.statuses .gantt__row.row-late { fill: rgb(217 72 15 / 10%);}
.statuses .gantt__row.row-blocked { fill: rgb(107 114 128 / 18%);}
/* The legend above the chart. */.statuses .legend { display: flex; flex-wrap: wrap; gap: 0.25rem 1rem; margin: 0 0 0.5rem; padding: 0; list-style: none; font-size: 0.8125rem;}
.statuses .legend li { display: flex; align-items: center; gap: 0.375rem;}
.statuses .legend li::before { content: ''; width: 0.75rem; height: 0.75rem; border-radius: 2px; background: var(--swatch);}
.statuses .legend [data-status='done'] { --swatch: var(--status-done);}
.statuses .legend [data-status='active'] { --swatch: var(--status-active);}
.statuses .legend [data-status='late'] { --swatch: var(--status-late);}
.statuses .legend [data-status='blocked'] { --swatch: var(--status-blocked);}
.statuses .legend [data-status='review'] { --swatch: var(--status-review);}Files
data.ts: the plan withclassNameandrowClassName(tasks), andcoloured, the same plan withcolorandprogressColor. ATask<Meta>carries its status indata.options.ts:display.rowStripes, row and bar heights, the first scroll.styles.css: the status classes (one custom property each) and the row tints.vanilla.tsandreact.tsx: the two selects. The tasks are swapped withsetTasks, the stripes withsetOptions({ display })(React: thedisplayprop).
Options
| Option | Value | What it does |
|---|---|---|
task.className |
'is-done', 'is-phase is-active', … |
Added to the class of the bar (g.gantt__bar); several classes separated by spaces |
task.rowClassName |
'row-late', 'row-blocked' |
Added to the class of the task’s row (rect.gantt__row) |
task.color, task.progressColor |
'#0f9d8a', '#0a6b5d' (QA; all in coloured) |
--gantt-bar-color and --gantt-bar-progress-color on the bar |
display.rowStripes |
'group'; the select: 'row', false |
Zebra stripes: every other row, every other parent with its children, or none |
layout.rowHeight, layout.barHeight |
34, 20 |
Shorter rows than the default 48 and 30, so the whole plan is on the screen |
viewMode |
'week' |
The scale of the timeline (React: defaultViewMode) |
scroll.initial |
'first-task' |
Opens on the plan |
What to try
Choose “colours in the data”: the bars keep their colours (they now come from color), but the red
and grey row tints are gone, because a row can only be styled by a class. QA is teal in both
modes: its own color and progressColor win over its class. Switch Row stripes between
'group', 'row' and false: with 'group' the stripes follow the phases, with 'row' they
follow the rows. A tinted row paints over its stripe. Hover a bar and click an arrow, as in any
chart: the hover outline, the selection and the conflict marks are the library’s and combine with
your classes.
Notes
- A class sets a custom property, not the paint.
.gantt__bar.is-late { --gantt-bar-color: … }is enough: the track, the milestone diamond and the progress (the bar colour mixed towards black) all read it. Set--gantt-bar-progress-colorin the class too if the progress should be another colour, and--gantt-bar-label-colorfor the label. Settingfillon.gantt__bar-trackyourself also works, but the progress colour, which is derived from--gantt-bar-color, would not follow it. - Precedence.
task.coloris written on the bar element itself, so it beats a class; a class beats the theme’s--gantt-bar-coloron.gantt, which the bar inherits. A rule of yours is not in@layer gantt, so it beats the library’s own rules without!important. - A row class paints the whole row, over the stripe and under the bars: use a colour with an
alpha (
rgb(217 72 15 / 10%)), so the grid lines and days off stay visible. There is nocolorcounterpart for a row. - A phase is a task. Give a parent a class of its own (
is-phasehere: a darker progress) next to its status. Its dates are its own; the library does not roll them up from the children. - Dark theme. The status colours are mid-tones that work on both backgrounds. If they should
differ, add
.gantt[data-theme='dark'] .is-done { … }rules, or derive the colour fromcolor-mix(); see Theming for how a theme is written. - Which to pick. Classes when the look is a design decision (a palette, a theme, a print sheet) or when you also want a row style. Colours when they are data (a label colour a user picked, a project’s colour) and not worth a rule each.