Custom view modes
The viewModes option changes the zoom levels of the chart. It takes an object keyed by view mode
id, and each key does one of two things:
- the id of a preset (
day,week, …) is an override: it is merged over the preset, and every field it gives replaces the preset’s field. Heredayis wider and has a weekday in its header, andweeksnaps a drag to whole weeks and has no thick line at the start of a month; - any other id is a new view mode, which has to be complete (
unit,pxPerDay,headers). Heresprinthas a column of two weeks and a header cell that says which sprint it is, andquarterhas a column of a quarter, a unit no preset uses.
The new modes are in the select next to the two overridden presets; the presets that are not
mentioned (hour, month, …) stay as they are and can still be switched to with setViewMode.
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';
// The team works in two-week sprints that start on a Monday: sprint 5 is 9 to 20 March, sprint 6// 23 March to 3 April, and so on. Most tasks fill whole sprints; the three long ones (a platform// programme, a marketing push and hiring) are what the quarter view is for.export const tasks: Task[] = [ { id: 'discovery', name: 'Discovery', start: '2026-03-09', end: '2026-03-20', progress: 100 }, { id: 'design', name: 'Design', start: '2026-03-23', end: '2026-04-03', progress: 100 }, { id: 'backend', name: 'Backend', start: '2026-04-06', end: '2026-04-17', progress: 70 }, { id: 'frontend', name: 'Frontend', start: '2026-04-06', end: '2026-05-01', progress: 40 }, { id: 'docs', name: 'Docs', start: '2026-04-20', end: '2026-05-01', progress: 10 }, { id: 'review', name: 'Review', start: '2026-05-04', end: '2026-05-15' }, { id: 'qa', name: 'QA', start: '2026-05-18', end: '2026-05-29' }, { id: 'release', name: 'Release', type: 'milestone', start: '2026-06-01' },
{ id: 'platform', name: 'Platform programme', start: '2026-03-09', end: '2026-07-10' }, { id: 'marketing', name: 'Marketing push', start: '2026-04-20', end: '2026-06-26' }, { id: 'hiring', name: 'Hiring', start: '2026-03-09', end: '2026-08-28', progress: 30 },];
export const links: Link[] = [ { source: 'discovery', target: 'design' }, { source: 'design', target: 'backend' }, { source: 'design', target: 'frontend' }, { source: 'backend', target: 'review' }, { source: 'frontend', target: 'review' }, { source: 'docs', target: 'review' }, { source: 'review', target: 'qa' }, { source: 'qa', target: 'release' },];import type { GanttOptions } from '@ganttlane/dom';
/** The ids of the view modes below, for the select. */export const VIEW_MODES = ['day', 'week', 'sprint', 'quarter'] as const;
export const options = { // A view mode of our own, defined below. viewMode: 'sprint', // `viewModes` is an object keyed by view mode id. An id of a preset (`day`, `week`, ...) is an // override: it is merged over the preset, and each field it gives replaces the preset's. // Any other id is a new view mode, which has to be complete: `unit`, `pxPerDay` and `headers`. viewModes: { day: { // Wider columns: pixels per day (the preset is 40). pxPerDay: 64, // Header rows, top first; a list replaces the preset's list as a whole. `format` is the // options of an `Intl.DateTimeFormat`, so it follows the chart's `locale`. headers: [ { unit: 'month', format: { month: 'long', year: 'numeric' } }, { unit: 'day', format: { weekday: 'short', day: 'numeric' } }, ], // Timeline before and after the data (the preset is '14d'). padding: '30d', }, week: { // `false` takes away what the preset has: no thick line at the start of each month. majorLine: false, // A drag moves a bar by whole weeks (the preset snaps to '1d'). snap: '1w', }, // A new view mode: a fortnight per column. sprint: { label: 'Sprint', // One column is `step` units: two weeks. A `unit` of 'quarter' is only for modes of your own. unit: 'week', step: 2, pxPerDay: 12, headers: [ { unit: 'month', format: { month: 'long', year: 'numeric' } }, { unit: 'week', step: 2, // Or a function for the cell: it is given the cell's range (`start`, and `end`, which is // exclusive), the chart's locale and its date adapter. Sprint 1 started on 12 January. format: (range, locale, adapter) => { const sprintOne = adapter.fromParts({ year: 2026, month: 1, day: 12 }); const sprint = Math.floor(adapter.diffDays(sprintOne, range.start) / 14) + 1; const day = { day: 'numeric', month: 'short' } as const; const first = adapter.format(range.start, day, locale); const last = adapter.format(adapter.add(range.end, -1, 'day'), day, locale); return `Sprint ${String(sprint)} · ${first} – ${last}`; }, }, ], // A thick grid line at the start of every month, between the columns too. majorLine: { unit: 'month' }, // A faint line every week, inside a column. minorLine: '1w', snap: '1d', // `[before, after]`: a short lead-in, and room for what comes next. padding: ['2w', '6w'], }, // Another one, with a unit that no preset has. quarter: { label: 'Quarter', unit: 'quarter', pxPerDay: 3, headers: [ { unit: 'year', format: { year: 'numeric' } }, { unit: 'quarter', format: (range, _locale, adapter) => `Q${String(Math.floor((adapter.getParts(range.start).month - 1) / 3) + 1)}`, }, ], majorLine: { unit: 'year' }, snap: '1w', padding: '1q', }, }, // Open on the first task: the default, 'today', is far from these dates. scroll: { initial: 'first-task' }, // Compact rows, so that every task fits without scrolling down. layout: { rowHeight: 34, barHeight: 22 },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';
import { createGantt } from '@ganttlane/dom';
import { links, tasks } from './data';import { describe } from './helpers';import { options, VIEW_MODES } from './options';
export function mount(container: HTMLElement): () => void { const root = document.createElement('div'); const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); // The chart fills its container and scrolls inside it, so the container needs a height. const chart = document.createElement('div'); chart.style.height = '450px'; const status = document.createElement('p'); status.textContent = 'Pick a view mode: day and week are presets with overrides, the others are new.'; root.append(toolbar, chart, status); container.append(root);
const gantt = createGantt(chart, { tasks, links, ...options, // The handler gets the `ViewMode` that is now in use, whether it is a preset or one of ours. onViewModeChange: (viewMode) => { status.textContent = describe(viewMode); }, });
const controller = new AbortController(); const select = document.createElement('select'); for (const id of VIEW_MODES) { const option = document.createElement('option'); option.value = id; option.textContent = id; select.append(option); } select.value = options.viewMode; select.addEventListener('change', () => gantt.setViewMode(select.value), { signal: controller.signal, }); const label = document.createElement('label'); label.append('View mode ', select); toolbar.append(label);
return () => { controller.abort(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';
import { Gantt } from '@ganttlane/react';import { useState } from 'react';
import { links, tasks } from './data';import { describe } from './helpers';import { options, VIEW_MODES } from './options';
export default function Example() { // `viewMode` is controlled by its prop: the chart shows the one your state holds. const [viewMode, setViewMode] = useState<string>(options.viewMode); const [status, setStatus] = useState( 'Pick a view mode: day and week are presets with overrides, the others are new.', );
return ( <> <div role="toolbar"> <label> View mode{' '} <select value={viewMode} onChange={(event) => { setViewMode(event.target.value); }} > {VIEW_MODES.map((id) => ( <option key={id} value={id}> {id} </option> ))} </select> </label> </div> <Gantt // The chart fills its container and scrolls inside it, so the container needs a height. style={{ height: 450 }} // The chart owns the data (uncontrolled): these are where it starts. defaultTasks={tasks} defaultLinks={links} {...options} viewMode={viewMode} // The handler gets the `ViewMode` that is now in use, whether it is a preset or one of ours. onViewModeChange={(mode) => { setStatus(describe(mode)); }} /> <p>{status}</p> </> );}import type { ViewMode } from '@ganttlane/dom';
/** * Says what a view mode is made of: the width of a column, the zoom and the snap step of a drag. * * @param viewMode - The `ViewMode` that `onViewModeChange` passes. * @returns A sentence. */export function describe(viewMode: ViewMode): string { const step = viewMode.step ?? 1; const column = `${String(step)} ${viewMode.unit}${step === 1 ? '' : 's'}`; const snap = typeof viewMode.snap === 'string' ? viewMode.snap : 'its own step'; const rows = viewMode.headers.length; return `${viewMode.label ?? viewMode.id}: a column is ${column}, ${String(viewMode.pxPerDay)} px per day, ${String(rows)} header rows, a drag snaps to ${snap}`;}Files
data.ts: eight tasks that fill two-week sprints (one a milestone), three that run for months, and links.options.ts:viewModes, with the two overrides and the two new modes.helpers.ts:describe, the status line.vanilla.tsandreact.tsx: the select andonViewModeChange.
What a view mode is made of
| Field | What it is | sprint |
|---|---|---|
id |
The name viewMode and setViewMode use; the key of the object |
'sprint' |
label |
A display name for your own switcher, apart from the id (translate it here) | 'Sprint' |
unit |
The unit of a column: hour, day, week, month, quarter or year |
'week' |
step |
How many units a column is (default 1) | 2 |
pxPerDay |
The zoom: how many pixels a day is wide | 12 |
headers |
The header rows, top first: a unit, a step and a format |
month, then two weeks |
majorLine |
Thick grid lines: { unit } at every start of a unit, or false |
{ unit: 'month' } |
minorLine |
Faint grid lines every duration, shown while they are far enough apart | '1w' |
snap |
The step a drag moves by; scheduling.snap overrides it for every mode |
'1d' |
padding |
Timeline before and after the data: one duration or [before, after] |
['2w', '6w'] |
A header format is the options of an Intl.DateTimeFormat ({ month: 'long' }), or a function
(range, locale, adapter) => string for a cell that has to say more than a date does. The sprint
header is one: it counts the sprints from the date the first one started, and writes the first and
the last day of the cell. It does its date arithmetic with the adapter it is given
(diffDays, fromParts, add, format), which reads the dates as wall-clock time.
Options
| Option | Value | What it does |
|---|---|---|
viewMode |
'sprint' |
The view mode in use, a new one (React: a controlled prop) |
viewModes.day |
pxPerDay, headers, padding |
Overrides of a preset: wider columns, a weekday in the header, more padding |
viewModes.week |
majorLine: false, snap: '1w' |
Overrides of a preset: no month lines, a drag moves by whole weeks |
viewModes.sprint |
a complete view mode | A new mode: unit: 'week', step: 2, three more fields below it |
viewModes.quarter |
a complete view mode | A new mode: unit: 'quarter', which presets do not use |
scroll.initial |
'first-task' |
Opens on the plan |
layout.rowHeight, barHeight |
34, 22 |
Compact rows, so that every task fits |
onViewModeChange |
a function (in the example) | Called with the ViewMode that is now in use |
What to try
Start in sprint: each column is a fortnight, headed with its number and its dates, with a faint line at every week and a thick one at every month. Every bar of the plan starts and ends on the edge of a sprint. Pick quarter and the three long tasks make sense; the sprint-sized ones are a few pixels wide. Pick day and the columns are wider than the preset’s (64 px against 40) and have the weekday in them. In week, drag a bar: it moves by whole weeks, and there are no month lines.
Notes
- An override is a merge, field by field.
week: { snap: '1w' }keeps the preset’s unit, headers and the rest. A list such asheadersis replaced as a whole, not concatenated. GivefalsetomajorLine,minorLineorsnapto take the preset’s away. - A new mode must be complete. A mode without
unit,pxPerDayorheadersis dropped and reported as aninvalid-view-modeissue, whichgetIssues()andonIssueslist. An invalid override of a preset leaves the preset as it was. viewModesas an array (a list of complete modes) replaces the presets altogether, so the chart has only the modes you list. The object form used here keeps them.- Columns do not move. Header cells and grid lines are anchored to fixed points (a fortnight starts on every other Monday counted from the first week of 1970, a quarter on January, April, July and October), not to the start of the data, so they stay put while the chart scrolls or grows. That is why the sprints here start on 12 January.
- A thick line is not a column line.
majorLine: { unit: 'month' }is drawn at every month start, also between the columns of a sprint; a function(date) => booleanis asked about column starts only. - React.
viewModeis a controlled prop, so the select changes your state and the chart follows it.optionsis spread as props andviewModeis passed after it, for it to win. TheviewModesobject is defined once, outside the component: a headerformatthat is a function is compared by identity, so one written inline would be a change on every render.