Infinite timeline
A chart normally covers its tasks plus some padding, and scrolling stops there. With
timeline.infinite the timeline grows by itself: when you scroll within threshold pixels of
either edge, that edge moves out by extendBy, and the chart keeps the date under its left edge
where it is, so nothing jumps. maxSpan cuts off the side you left, bounds stop the growth at
dates you choose, and scrollToDate to a date outside the timeline grows it first. Every change
is reported with onRangeChange, with the new range and the reason: the hook for loading tasks as
the timeline reaches new dates.
The select puts a different value into timeline.infinite; the line under the chart is written
by onRangeChange and counts the changes since you chose.
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';
// Ten tasks in one quarter. The plan is short on purpose: the chart is about what happens when you// scroll past its end.export const tasks: Task[] = [ { id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-13', progress: 100 }, { id: 'backend', name: 'Backend', start: '2026-03-16', end: '2026-04-10', progress: 70 }, { id: 'frontend', name: 'Frontend', start: '2026-03-23', end: '2026-04-17', progress: 50 }, { id: 'docs', name: 'Docs', start: '2026-04-06', end: '2026-04-24', progress: 20 }, { id: 'review', name: 'Review', start: '2026-04-20', end: '2026-04-24' }, { id: 'qa', name: 'QA', start: '2026-04-27', end: '2026-05-08' }, { id: 'beta', name: 'Beta', start: '2026-05-11', end: '2026-05-22' }, { id: 'launch', name: 'Launch', type: 'milestone', start: '2026-05-25' }, { id: 'marketing', name: 'Marketing', start: '2026-04-13', end: '2026-05-29' }, { id: 'monitor', name: 'Monitor', start: '2026-05-26', end: '2026-06-12' },];
export const links: Link[] = [ { source: 'design', target: 'backend' }, { source: 'design', target: 'frontend' }, { source: 'backend', target: 'review' }, { source: 'frontend', target: 'review' }, { source: 'review', target: 'qa' }, { source: 'qa', target: 'beta' }, { source: 'beta', target: 'launch' }, { source: 'launch', target: 'monitor' },];import type { GanttOptions } from '@ganttlane/dom';
/** What `timeline.infinite` can be: `false`, `true` for the defaults, or an object. */type Infinite = NonNullable<GanttOptions['timeline']>['infinite'];
/** The settings the select offers, each with the value it puts in `timeline.infinite`. */export const SETTINGS = [ { id: 'off', label: 'false: a fixed range', infinite: false }, { id: 'defaults', label: 'true: the defaults', infinite: true }, { id: 'small', label: 'threshold 200, extendBy 1mo', // Grow sooner (200 px from the edge instead of a viewport width), and by a month at a time. infinite: { threshold: 200, extendBy: '1mo' }, }, { id: 'span', label: 'extendBy 1mo, maxSpan 6mo', // Never keep more than six months: the edge you scrolled away from is cut off. infinite: { extendBy: '1mo', maxSpan: '6mo' }, }, { id: 'bounds', label: 'bounds: 2026 only', // Grow, but never past these dates (a date-only `max` includes its whole day). infinite: { bounds: { min: '2026-01-01', max: '2026-12-31' } }, },] as const satisfies readonly { id: string; label: string; infinite: Infinite }[];
export const options = { viewMode: 'week', timeline: { // Off by default. With `true` the timeline grows by itself when the viewport gets within a // viewport width of an edge (`threshold`), by two viewport widths of time at a time // (`extendBy`), in both directions, and the date under the left edge stays where it is. infinite: true, }, // Open on the plan, with the empty timeline before it in reach. scroll: { initial: 'first-task' }, // Compact rows, so that every task fits without scrolling down. layout: { rowHeight: 32, barHeight: 20 },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';
import { createGantt } from '@ganttlane/dom';
import { links, tasks } from './data';import { describe } from './helpers';import { options, SETTINGS } from './options';
export function mount(container: HTMLElement): () => void { const root = document.createElement('div'); const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); const actions = document.createElement('div'); actions.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 = '400px'; const status = document.createElement('p'); status.textContent = 'Scroll the chart sideways, as far as you like, or press a button.'; root.append(toolbar, actions, chart, status); container.append(root);
// How many times each kind of change has happened since the setting was chosen. const counts = new Map<string, number>(); const gantt = createGantt(chart, { tasks, links, ...options, // The range of the timeline changed: why, and what it is now. The first one is `'initial'`; // an infinite timeline adds `'extend-start'`, `'extend-end'`, `'trim-start'` and `'trim-end'`. onRangeChange: (range) => { counts.set(range.reason, (counts.get(range.reason) ?? 0) + 1); status.textContent = describe(range, range.reason, counts); }, });
const controller = new AbortController(); const { signal } = controller;
const select = document.createElement('select'); for (const { id, label } of SETTINGS) { const option = document.createElement('option'); option.value = id; option.textContent = label; select.append(option); } select.value = 'defaults'; select.addEventListener( 'change', () => { const setting = SETTINGS.find((candidate) => candidate.id === select.value); if (!setting) return; counts.clear(); status.textContent = 'Setting changed: scroll the chart sideways.'; // `timeline` merges field by field with the options set before. gantt.setOptions({ timeline: { infinite: setting.infinite } }); }, { signal }, ); const label = document.createElement('label'); label.append('timeline.infinite: ', select); toolbar.append(label);
const addButton = (name: string, onClick: () => void): void => { const button = document.createElement('button'); button.type = 'button'; button.textContent = name; button.addEventListener('click', onClick, { signal }); actions.append(button); }; // A date far outside the timeline: it grows to reach it (unless `bounds` say no). A jump is // `instant` here, so that the chart does not glide for a year. addButton('Go to Jan 2025', () => { gantt.scrollToDate('2025-01-06', { behavior: 'instant', align: 'center' }); }); addButton('Go to Dec 2027', () => { gantt.scrollToDate('2027-12-06', { behavior: 'instant', align: 'center' }); }); addButton('Back to the plan', () => { gantt.scrollToTask('design', { behavior: 'instant' }); });
return () => { controller.abort(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';
import { Gantt, type GanttHandle } from '@ganttlane/react';import { useRef, useState } from 'react';
import { links, tasks } from './data';import { describe } from './helpers';import { options, SETTINGS } from './options';
export default function Example() { const chart = useRef<GanttHandle>(null); const [setting, setSetting] = useState<(typeof SETTINGS)[number]['id']>('defaults'); const [status, setStatus] = useState( 'Scroll the chart sideways, as far as you like, or press a button.', ); // How many times each kind of change has happened since the setting was chosen. A ref, not state: // several changes can come before React renders again, and none may be lost. const counts = useRef(new Map<string, number>()); // In React `viewMode` is a controlled prop, and `defaultViewMode` is its uncontrolled twin: here // nothing changes the view mode, so the chart owns it and starts from the option. const { viewMode, ...rest } = options; const infinite = SETTINGS.find((candidate) => candidate.id === setting)?.infinite ?? false;
return ( <> <div role="toolbar"> <label> timeline.infinite:{' '} <select value={setting} onChange={(event) => { const next = SETTINGS.find((candidate) => candidate.id === event.target.value); if (!next) return; counts.current.clear(); setStatus('Setting changed: scroll the chart sideways.'); setSetting(next.id); }} > {SETTINGS.map(({ id, label }) => ( <option key={id} value={id}> {label} </option> ))} </select> </label> </div> <div role="toolbar"> {/* A date far outside the timeline: it grows to reach it (unless `bounds` say no). A jump is `instant` here, so that the chart does not glide for a year. */} <button type="button" onClick={() => chart.current?.scrollToDate('2025-01-06', { behavior: 'instant', align: 'center' }) } > Go to Jan 2025 </button> <button type="button" onClick={() => chart.current?.scrollToDate('2027-12-06', { behavior: 'instant', align: 'center' }) } > Go to Dec 2027 </button> <button type="button" onClick={() => chart.current?.scrollToTask('design', { behavior: 'instant' })} > Back to the plan </button> </div> <Gantt ref={chart} // The chart fills its container and scrolls inside it, so the container needs a height. style={{ height: 400 }} // The chart owns the data (uncontrolled): these are where it starts. defaultTasks={tasks} defaultLinks={links} defaultViewMode={viewMode} {...rest} timeline={{ infinite }} // The range of the timeline changed: why, and what it is now. The first one is // `'initial'`; an infinite timeline adds `'extend-start'`, `'extend-end'`, `'trim-start'` // and `'trim-end'`. onRangeChange={(range) => { counts.current.set(range.reason, (counts.current.get(range.reason) ?? 0) + 1); setStatus(describe(range, range.reason, counts.current)); }} /> <p>{status}</p> </> );}import type { DateRange } from '@ganttlane/dom';
const day = new Intl.DateTimeFormat('en', { day: 'numeric', month: 'short', year: 'numeric' });const DAY_MS = 86_400_000;
/** * Says what the timeline is now: its first and last day, its length, and why it last changed, with * how often each kind of change has happened since the setting was chosen. * * @param range - What `onRangeChange` reports: `Date`s with the wall-clock time of the chart, the * end exclusive. * @param reason - Why the range changed (`'extend-start'`, `'extend-end'`, `'trim-end'`, …). * @param counts - How many times each reason has come up. * @returns A sentence. */export function describe( range: DateRange, reason: string, counts: ReadonlyMap<string, number>,): string { const days = Math.round((range.end.getTime() - range.start.getTime()) / DAY_MS); const history = [...counts].map(([why, times]) => `${why} ×${String(times)}`).join(', '); return `Range: ${day.format(range.start)} to ${day.format(range.end)}, ${String(days)} days (last change: ${reason}${history === '' ? '' : `; so far ${history}`})`;}Files
data.ts: ten tasks in one quarter, and their links.options.ts:timeline.infinite, and the five settings the select offers.helpers.ts:describe, which writes the status line.vanilla.ts:onRangeChange,setOptions({ timeline })andscrollToDate.react.tsx: the same with thetimelineandonRangeChangeprops and theref.
Options
| Option | Value | What it does |
|---|---|---|
timeline.infinite |
true, false or an object (the select) |
Turns the infinite timeline on (it is off by default); an object also sets the rest |
timeline.infinite.threshold |
200 |
Distance in px from an edge at which it is extended (default: the viewport’s width) |
timeline.infinite.extendBy |
'1mo' |
The least one extension adds (default: two viewport widths of time) |
timeline.infinite.maxSpan |
'6mo' |
The longest timeline kept: the edge you scrolled away from is trimmed |
timeline.infinite.bounds |
{ min: '2026-01-01', max: '2026-12-31' } |
Dates it never extends past; a date-only max includes its whole day |
onRangeChange |
a function | Called with { start, end, reason } whenever the range changes |
viewMode |
'week' |
The scale of the timeline (React: defaultViewMode, as nothing here changes it) |
scroll.initial |
'first-task' |
Opens on the plan, with the empty timeline before it in reach |
layout.rowHeight, barHeight |
32, 20 |
Compact rows, so that every task fits |
The five settings of the select:
| Select | timeline.infinite |
|---|---|
false: a fixed range |
false |
true: the defaults |
true |
threshold 200, extendBy 1mo |
{ threshold: 200, extendBy: '1mo' } |
extendBy 1mo, maxSpan 6mo |
{ extendBy: '1mo', maxSpan: '6mo' } |
bounds: 2026 only |
{ bounds: { min: '2026-01-01', max: '2026-12-31' } } |
What to try
With true, scroll to the right end of the chart (the wheel, a trackpad, the scrollbar): the
timeline reaches out by about two screens each time you come near its end, and the line under the
chart counts extend-end. Do the same to the left for extend-start. Go to Dec 2027 jumps
to a date far outside the timeline, which grows to include it (scroll-to), and Back to the
plan comes home. Choose false: the timeline snaps back to the plan and its padding, and
scrolling stops at its ends. Choose extendBy 1mo, maxSpan 6mo and scroll a long way in one
direction: the timeline never gets much longer than six months, as the edge behind you is trimmed
(trim-start, trim-end). Choose bounds: 2026 only and neither scrolling nor the buttons get
you out of 2026.
Notes
- The reasons
onRangeChangegives:'initial'(the first range),'extend-start'and'extend-end'(the timeline grew),'trim-start'and'trim-end'(it was cut),'scroll-to'(ascrollTo*outside it),'drag'(a bar was dragged out of it),'resize'(the viewport changed),'view-mode'and'zoom'(the scale changed),'data'and'options'(new tasks or options re-resolved it). The event carriesDates:startand the exclusiveend. - Changing the setting with
setOptions({ timeline: { infinite } })re-resolves the range ('options') only when it changes: from a grown timeline tofalseit snaps back to the data.timelineis merged field by field, sopadding,rangeandincludeTodaykeep their values. - Why it does not jump. Extending the start moves every date to the right on the page. The
chart adds the same distance to
scrollLeftin the same frame, so the date at the left edge stays where it was; extending the end moves nothing. - There is a ceiling anyway. Browsers cannot draw an element of any width, so the content is
never kept wider than about 4 million px: at the hour view (960 px a day) that is about eleven
years, at the week view (20 px a day) about 550.
maxSpanis the limit you choose, in time. - A fixed range of your own is
timeline.range: { start, end };timeline.includeTodaykeeps today inside an automatic range. Neither is needed for the infinite timeline. scroll.initial: 'start'and'end'mean the ends of the data (and its padding), also with an infinite timeline, which then has more timeline beyond them to scroll into.- React.
timelineis an ordinary prop, so the select changes your state and the chart merges it withsetOptionsfor you.onRangeChangecan fire several times before React renders again, so the example keeps the counts in a ref.