Grid and today
The body of the chart is a grid, and a handful of options and tokens decide what is on it:
display.gridLines: the vertical lines of the columns, the horizontal lines of the rows, both (the default), or none.- The today line (
display.today): the line in the grid, the dot in the header, and the highlighted header cell. It follows the clock, or thenowoption. - Days off (
display.nonWorkingDays): weekends and holidays, as a fill, stripes, or not drawn. - Major lines: the heavier lines at the start of a week, a month or a quarter. Each view mode
says where its own are (
majorLine), and you can say otherwise. - Hover and selection: the range of a hovered bar in the header, a selected bar, a selected link. They are tokens, so a stylesheet of yours changes them without a rule per state.
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';
// 2026-03-02 is a Monday. Four weeks of work around a fixed "today" (2026-03-18, see// `options.ts`), so the today line crosses the middle of the plan and the weekends show.export const tasks: Task[] = [ { id: 'research', name: 'Research', start: '2026-03-02', end: '2026-03-06', progress: 100 }, { id: 'design', name: 'Design', start: '2026-03-09', end: '2026-03-13', progress: 90 }, { id: 'backend', name: 'Backend', start: '2026-03-12', end: '2026-03-24', progress: 55 }, { id: 'frontend', name: 'Frontend', start: '2026-03-16', end: '2026-03-25', progress: 25 }, { id: 'docs', name: 'Docs', start: '2026-03-18', end: '2026-03-24', progress: 10 }, { id: 'review', name: 'Review', start: '2026-03-26', end: '2026-03-27', progress: 0 }, { id: 'launch', name: 'Launch', type: 'milestone', start: '2026-03-30' },];
export const links: Link[] = [ { source: 'research', target: 'design' }, { source: 'design', target: 'frontend' }, { source: 'backend', target: 'review' }, { source: 'frontend', target: 'review' }, { source: 'review', target: 'launch' },];import type { GanttOptions } from '@ganttlane/dom';
export const options = { // A fixed "today", at noon, so that the today line is on the plan whenever you read this. An // application leaves `now` out: the chart then uses the clock. now: () => '2026-03-18T12:00', display: { // Which grid lines the body draws: 'both' (the default), 'vertical', 'horizontal' or 'none'. gridLines: 'both', // The today line, its marker in the header and the highlighted header cell. Default `true`. today: true, // Days off: 'fill' (the default), 'stripes' (a pattern), or false (not drawn). nonWorkingDays: 'fill', // The range of the hovered bar, highlighted in the header. Default `true`. headerRangeHighlight: true, }, viewMode: 'week', // Shorter rows than the default (48 and 30 px), so that the whole plan is on the screen. layout: { rowHeight: 34, barHeight: 22 }, // Open on the plan (today is on it, thanks to `now`). scroll: { initial: 'first-task' },} satisfies Partial<GanttOptions>;
const DAY = 86_400_000;
/** * A major line wherever a predicate says so: `majorLine` may be `{ unit }` (a line at the start of * every week, month, …), `false`, or a function of the start of a column, in floating milliseconds. * This one draws a line at midnight on every second Monday, a sprint: 1970-01-05 was a Monday, and * 2026-03-02 is a whole number of fortnights after it. */const everySprint = (date: number): boolean => date % DAY === 0 && (Math.floor(date / DAY) - 4) % 14 === 0;
/** * View modes replaced field by field: each mode keeps what it has (columns, headers, snap) and * takes the `majorLine` given here. A mode that is not named keeps its own (the month view's * lines are at quarters). `viewModes` is taken whole by `setOptions`. */export const sprintLines = { day: { majorLine: everySprint }, week: { majorLine: everySprint },} satisfies NonNullable<GanttOptions['viewModes']>;
export const noLines = { day: { majorLine: false }, week: { majorLine: false }, month: { majorLine: false },} satisfies NonNullable<GanttOptions['viewModes']>;import '@ganttlane/dom/styles.css';import './styles.css';
import { createGantt, type GanttOptions, type GridLinesOption, type NonWorkingDaysOption,} from '@ganttlane/dom';
import { links, tasks } from './data';import { noLines, options, sprintLines } from './options';
const GRID_LINES: readonly GridLinesOption[] = ['both', 'vertical', 'horizontal', 'none'];const DAYS_OFF: Record<string, NonWorkingDaysOption> = { fill: 'fill', stripes: 'stripes', off: false,};const VIEW_MODES = ['day', 'week', 'month'] as const;// `viewModes` replaces the `majorLine` of the modes it names; `undefined` puts the presets back.const MAJOR_LINES: Record<string, GanttOptions['viewModes']> = { preset: undefined, sprint: sprintLines, none: noLines,};
export function mount(container: HTMLElement): () => void { const root = document.createElement('div'); const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); // The container of the chart. The look is its `data-look` attribute, which `styles.css` selects // on (`[data-look='violet'] .gantt`). const chart = document.createElement('div'); chart.style.height = '320px'; chart.dataset.look = 'library'; const status = document.createElement('p'); root.append(toolbar, chart, status); container.append(root);
const gantt = createGantt(chart, { tasks, links, ...options });
// Clicking a bar only reports it (`taskClick`); selecting it is the application's call. gantt.on('taskClick', (record) => { gantt.select([record.id]); });
let gridLines: GridLinesOption = options.display.gridLines; let nonWorkingDays: NonWorkingDaysOption = options.display.nonWorkingDays; let today: boolean = options.display.today; let major = 'preset'; const show = (): void => { status.textContent = `gridLines: ${gridLines} · nonWorkingDays: ${String(nonWorkingDays)} · today: ${String(today)} · major lines: ${major}. Click a bar to select it.`; }; show();
const controller = new AbortController(); const listen = (element: HTMLElement, onChange: () => void): void => { element.addEventListener('change', onChange, { signal: controller.signal }); }; const addSelect = ( label: string, choices: readonly string[], onChange: (id: string) => void, initial: string = choices[0] ?? '', ): void => { const wrapper = document.createElement('label'); const select = document.createElement('select'); for (const id of choices) { const option = document.createElement('option'); option.value = id; option.textContent = id; select.append(option); } select.value = initial; listen(select, () => onChange(select.value)); wrapper.append(`${label} `, select); toolbar.append(wrapper); }; const addCheckbox = (label: string, onChange: (checked: boolean) => void): void => { const wrapper = document.createElement('label'); const input = document.createElement('input'); input.type = 'checkbox'; input.checked = true; listen(input, () => onChange(input.checked)); wrapper.append(input, ` ${label}`); toolbar.append(wrapper); };
// `display` is merged field by field, so each control changes one field and nothing else. addSelect('Grid lines', GRID_LINES, (id) => { gridLines = GRID_LINES.find((option) => option === id) ?? 'both'; gantt.setOptions({ display: { gridLines } }); show(); }); addSelect('Days off', Object.keys(DAYS_OFF), (id) => { nonWorkingDays = DAYS_OFF[id] ?? 'fill'; gantt.setOptions({ display: { nonWorkingDays } }); show(); }); addSelect( 'View', VIEW_MODES, (id) => { gantt.setViewMode(id); }, options.viewMode, ); addSelect('Major lines', Object.keys(MAJOR_LINES), (id) => { major = id; gantt.setOptions({ viewModes: MAJOR_LINES[id] }); show(); }); addSelect('Look', ['library', 'violet'], (id) => { chart.dataset.look = id; }); addCheckbox('Today', (checked) => { today = checked; gantt.setOptions({ display: { today } }); show(); }); addCheckbox('Header range', (headerRangeHighlight) => { gantt.setOptions({ display: { headerRangeHighlight } }); });
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 { links, tasks } from './data';import { noLines, options, sprintLines } from './options';
const GRID_LINES = ['both', 'vertical', 'horizontal', 'none'] as const;const DAYS_OFF = { fill: 'fill', stripes: 'stripes', off: false } as const;const VIEW_MODES = ['day', 'week', 'month'];const MAJOR_LINES = { preset: undefined, sprint: sprintLines, none: noLines } as const;type DaysOff = keyof typeof DAYS_OFF;type MajorLines = keyof typeof MAJOR_LINES;
function Select<T extends string>({ label, value, choices, onChange,}: { label: string; value: T; choices: readonly T[]; onChange: (value: T) => void;}) { return ( <label> {label}{' '} <select value={value} onChange={(event) => { const next = choices.find((choice) => choice === event.target.value); if (next) onChange(next); }} > {choices.map((choice) => ( <option key={choice} value={choice}> {choice} </option> ))} </select> </label> );}
export default function Example() { const chart = useRef<GanttHandle>(null); const [gridLines, setGridLines] = useState<(typeof GRID_LINES)[number]>( options.display.gridLines, ); const [daysOff, setDaysOff] = useState<DaysOff>('fill'); const [today, setToday] = useState<boolean>(options.display.today); const [headerRange, setHeaderRange] = useState<boolean>(options.display.headerRangeHighlight); const [major, setMajor] = useState<MajorLines>('preset'); // `viewMode` is controlled by its prop: the chart shows the one your state holds. const [viewMode, setViewMode] = useState<string>(options.viewMode); const [look, setLook] = useState('library');
return ( <> <div role="toolbar"> <Select label="Grid lines" value={gridLines} choices={GRID_LINES} onChange={setGridLines} /> <Select label="Days off" value={daysOff} choices={['fill', 'stripes', 'off']} onChange={setDaysOff} /> <Select label="View" value={viewMode} choices={VIEW_MODES} onChange={setViewMode} /> <Select label="Major lines" value={major} choices={['preset', 'sprint', 'none']} onChange={setMajor} /> <Select label="Look" value={look} choices={['library', 'violet']} onChange={setLook} /> <label> <input type="checkbox" checked={today} onChange={(e) => setToday(e.target.checked)} />{' '} Today </label> <label> <input type="checkbox" checked={headerRange} onChange={(e) => setHeaderRange(e.target.checked)} />{' '} Header range </label> </div> <Gantt ref={chart} style={{ height: 320 }} // The look is an attribute of the container; `styles.css` selects `[data-look] .gantt`. data-look={look} // In React `tasks`, `links` and `viewMode` are controlled props, and `defaultTasks` and // `defaultLinks` are where an uncontrolled chart starts (there is no `dataMode` prop). defaultTasks={tasks} defaultLinks={links} {...options} viewMode={viewMode} display={{ gridLines, nonWorkingDays: DAYS_OFF[daysOff], today, headerRangeHighlight: headerRange, }} viewModes={MAJOR_LINES[major]} // Clicking a bar only reports it; selecting it is the application's call. onTaskClick={(record) => chart.current?.select([record.id])} /> <p> gridLines: {gridLines} · nonWorkingDays: {String(DAYS_OFF[daysOff])} · today:{' '} {String(today)} · major lines: {major}. Click a bar to select it. </p> </> );}/* * "Violet, bold": the grid, the today line, and the hover and selection styles, as tokens. It is * switched by the `data-look` attribute of the chart's container (`[data-look] .gantt`: the * library declares its tokens on `.gantt` itself, so a token set on the container would never reach * it). These rules are not in a cascade layer and the library's are, so they win without * `!important`. * * The palette is three variables, given new values for the dark theme (`data-theme='dark'`, and * `'auto'` while the system prefers dark), and every token is derived from them. */
[data-look='violet'] .gantt { --look-accent: #7c3aed; --look-today: #db2777; --look-major: #b9a5f2; --look-grid: #e9e4f7;
/* The grid: lines, and the heavier lines at the start of a sprint, week or month */ --gantt-grid-line-color: var(--look-grid); --gantt-grid-major-line-color: var(--look-major); --gantt-grid-major-line-width: 2px; --gantt-nonworking-color: color-mix(in srgb, var(--look-accent) 9%, transparent);
/* Today: the line, and the marker and the cell in the header */ --gantt-today-color: var(--look-today); --gantt-today-width: 3px; --gantt-header-current-color: color-mix(in srgb, var(--look-today) 18%, transparent);
/* Hover and selection: the range of a hovered bar in the header, a selected bar, a selected link */ --gantt-header-range-color: color-mix(in srgb, var(--look-accent) 22%, transparent); --gantt-bar-selected-color: var(--look-accent); --gantt-bar-selected-width: 3px; --gantt-link-selected-color: var(--look-accent); --gantt-link-selected-width: 3.5px; --gantt-focus-ring-color: var(--look-accent);}
[data-look='violet'] .gantt[data-theme='dark'] { --look-accent: #a78bfa; --look-today: #f472b6; --look-major: #4f4488; --look-grid: #2a2540;}
@media (prefers-color-scheme: dark) { [data-look='violet'] .gantt[data-theme='auto'] { --look-accent: #a78bfa; --look-today: #f472b6; --look-major: #4f4488; --look-grid: #2a2540; }}
/* Not everything is a token: the dashes of the today line are a rule on its class. */[data-look='violet'] .gantt__today { stroke-dasharray: 8 5;}Files
data.ts: seven tasks and five links around the fixed “today”.options.ts: thedisplayoptions, a fixednow,layout, the first scroll, and twoviewModesoverrides: major lines every second Monday, and none.styles.css: the “violet, bold” look, as tokens, with its dark values.vanilla.tsandreact.tsx: the toolbar and the status line. A click selects a bar, so there is a selection to style.
Options
| Option | Value | What it does |
|---|---|---|
display.gridLines |
'both'; the select: the other three |
'both', 'vertical', 'horizontal' or 'none' |
display.today |
true; the checkbox |
The today line, its marker in the header and the highlighted header cell |
display.nonWorkingDays |
'fill'; the select: 'stripes', false |
How days off are shown |
display.headerRangeHighlight |
true; the checkbox |
The range of the hovered bar, highlighted in the header |
viewModes |
{ day: { majorLine } }, … |
Overrides merged over the presets: majorLine as a function of a column’s start, or false |
viewMode |
'week'; the select: 'day', 'month' |
The scale: each has its own major lines (React: a controlled prop) |
now |
() => '2026-03-18T12:00' |
A fixed today, so the line is on the plan. Leave it out in an application |
layout.rowHeight, barHeight |
34, 22 |
Shorter rows than the default 48 and 30, so the whole plan is on the screen |
scroll.initial |
'first-task' |
Opens on the plan |
The tokens of the “violet” look (styles.css): --gantt-grid-line-color, --gantt-grid-major-line-color
and -width, --gantt-nonworking-color, --gantt-today-color and -width,
--gantt-header-current-color, --gantt-header-range-color, --gantt-bar-selected-color and
-width, --gantt-link-selected-color and -width, and --gantt-focus-ring-color. The whole
table is in the @ganttlane/dom README.
Major lines of the view modes
| View mode | Column | Major line at the start of a | Minor lines (the snap step) |
|---|---|---|---|
hour |
1 hour | day | 15 minutes |
quarter-day |
6 hours | day | 1 hour |
half-day |
12 hours | day | 1 hour |
day |
1 day | week | none |
week |
1 week | month | none |
month |
1 month | quarter | none |
year |
1 year | none | none |
What to try
Switch View and watch the heavier lines move: weeks in the day view, months in the week view, quarters in the month view. Choose Major lines: “sprint” draws one every second Monday (a function of the start of a column), “none” removes them. Change Grid lines and Days off, and untick Today: the line, the dot and the header cell all go. Then choose the violet look: the today line is bold and dashed, the major lines are heavier. Hover a bar to see its range in the header, and click it for the selection outline; click an arrow for the selected-link colour. Untick Header range to turn the highlight off.
Notes
- Major lines are
{ unit }(a line at the start of every week, month, quarter, …), a function of the start of a column in floating milliseconds that returnstruewhere a line goes, orfalsefor none. AviewModesrecord is merged over the presets field by field, so the example namesmajorLineonly: the columns, headers and snap of the mode stay. The function here draws a line at midnight on every second Monday, and the month view is left out, so it keeps its quarters. - Minor lines are the snap steps inside a column (dotted, from
minorLine);gridLinesleaves them out while they are denser than a few pixels. - State is attributes: a major line has
data-major, the header cell that holds todaydata-current, a selected bardata-selected.display.today: falseremoves the line, the marker anddata-current. Anything a token does not cover is a rule on those names: the dashes of the today line instyles.cssare one,stroke-dasharrayon.gantt__today. - Clicking a bar only reports it (
taskClick); the example selects the bar in the handler (select([id])). Clicking an arrow selects the link by itself. - Why
[data-look] .gantt: the stylesheet declares each token on.gantt, the element inside your container, so a token set on the container is never inherited. See Theming. - Days off come from the calendar (
calendar.weekends,calendar.holidays);nonWorkingDaysonly decides how they look. The default weekend is Saturday and Sunday.