Scrolling and today
Where a chart is looking is part of its options. scroll.initial says where it opens, scroll.align
and scroll.behavior say how a scroll that code asks for lands and moves, and three methods do the
asking: scrollToToday(), scrollToDate(date) and scrollToTask(id). The today line and its
marker in the header are display.today, and the line under the chart is written from the
scroll event: every scroll, whether the user or a method made it, reports where the chart is and
which dates are in view (getVisibleRange()).
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';
// Fourteen tasks from February to July 2026: wide enough to scroll in both directions, and tall// enough that scrolling to a task moves the rows too. "Today" is pinned to 15 April (see// `options.ts`), which is in the middle of the plan.export const tasks: Task[] = [ { id: 'discovery', name: 'Discovery', start: '2026-02-02', end: '2026-02-13', progress: 100 }, { id: 'design', name: 'Design', start: '2026-02-16', end: '2026-03-06', progress: 100 }, { id: 'prototype', name: 'Prototype', start: '2026-03-09', end: '2026-03-20', progress: 100 }, { id: 'backend', name: 'Backend', start: '2026-03-23', end: '2026-04-24', progress: 60 }, { id: 'frontend', name: 'Frontend', start: '2026-03-23', end: '2026-05-08', progress: 40 }, { id: 'docs', name: 'Docs', start: '2026-04-13', end: '2026-05-15', progress: 15 }, { id: 'review', name: 'Review', start: '2026-05-11', end: '2026-05-22' }, { id: 'qa', name: 'QA', start: '2026-05-25', end: '2026-06-05' }, { id: 'beta', name: 'Beta', start: '2026-06-08', end: '2026-06-19' }, { id: 'release', name: 'Release', type: 'milestone', start: '2026-06-22' }, { id: 'launch', name: 'Launch', start: '2026-06-23', end: '2026-06-26' }, { id: 'retro', name: 'Retrospective', start: '2026-06-29', end: '2026-07-03' },
{ id: 'marketing', name: 'Marketing', start: '2026-04-27', end: '2026-06-19' }, { id: 'hiring', name: 'Hiring', start: '2026-02-16', end: '2026-07-03', progress: 35 },];
export const links: Link[] = [ { source: 'discovery', target: 'design' }, { source: 'design', target: 'prototype' }, { source: 'prototype', target: 'backend' }, { source: 'prototype', target: 'frontend' }, { source: 'backend', target: 'review' }, { source: 'frontend', target: 'review' }, { source: 'docs', target: 'review' }, { source: 'review', target: 'qa' }, { source: 'qa', target: 'beta' }, { source: 'beta', target: 'release' }, { source: 'release', target: 'launch' }, { source: 'launch', target: 'retro' },];import type { GanttOptions } from '@ganttlane/dom';
// What the controls of the example offer, and their types./** What `scroll.initial` takes here: four keywords, and a date (1 June). */export const INITIALS = ['today', 'first-task', 'start', 'end', '2026-06-01'] as const;/** What `scroll.align` takes: a side of the viewport (a fraction from 0 to 1 is valid too). */export const ALIGNS = ['start', 'center', 'end'] as const;/** What `scroll.behavior` takes (and 'auto', the browser's own). */export const BEHAVIORS = ['smooth', 'instant'] as const;export type Initial = (typeof INITIALS)[number];export type Align = (typeof ALIGNS)[number];export type Behavior = (typeof BEHAVIORS)[number];
export const options = { viewMode: 'week', // The source of "today": the system clock by default. It is pinned here so that the example // looks the same on every day (and so that "today" is inside this plan). Leave it out in an app. now: () => '2026-04-15', scroll: { // Where the chart opens, read when it is created: 'today' (the default), 'first-task', 'start' // or 'end' of the timeline, 'none', or a date such as '2026-06-01'. initial: 'today', // Where the target of a scroll lands in the viewport: 'start', 'center' (here), 'end', or a // fraction from 0 to 1. It also places the row of `scrollToTask` vertically. align: 'center', // How `scrollToToday`, `scrollToDate` and `scrollToTask` move: 'smooth' (the default), // 'instant' or 'auto' (the browser's own). behavior: 'smooth', }, // The today line in the grid and its marker in the header (the default). display: { today: true },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';
import { createGantt, type Gantt } from '@ganttlane/dom';
import { links, tasks } from './data';import { describe } from './helpers';import { type Align, ALIGNS, type Behavior, BEHAVIORS, type Initial, INITIALS, options,} 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, or press a button.'; root.append(toolbar, actions, chart, status); container.append(root);
// What the controls hold. `scroll.initial` is read when a chart is created, so a new one is // made when it changes; the rest is changed on the chart that is there, with `setOptions`. const state: { initial: Initial; align: Align; behavior: Behavior; today: boolean } = { initial: options.scroll.initial, align: options.scroll.align, behavior: options.scroll.behavior, today: options.display.today, };
const create = (): Gantt => { const created = createGantt(chart, { tasks, links, ...options, scroll: { initial: state.initial, align: state.align, behavior: state.behavior }, display: { today: state.today }, }); // The chart scrolled, by the user or by a call: where it is, in px and as dates. `on` is the // `onScroll` option for a chart that already exists, so the handler can ask it for the // visible range (the option's handler would run for the first scroll, inside `createGantt`). const show = (x: number, y: number): void => { status.textContent = describe(created.getVisibleRange(), x, y); }; created.on('scroll', ({ x, y }) => { show(x, y); }); const { timeline, scrollTop } = created.core.getSnapshot(); show(timeline.scrollLeft, scrollTop); return created; }; let gantt = create();
const controller = new AbortController(); const { signal } = controller;
const addSelect = <T extends string>( name: string, values: readonly T[], current: T, onChange: (value: T) => void, ): void => { const select = document.createElement('select'); for (const value of values) { const option = document.createElement('option'); option.value = value; option.textContent = value; select.append(option); } select.value = current; select.addEventListener( 'change', () => { const value = values.find((candidate) => candidate === select.value); if (value !== undefined) onChange(value); }, { signal }, ); const label = document.createElement('label'); label.append(`${name} `, select); toolbar.append(label); }; addSelect('Opens at', INITIALS, state.initial, (value) => { state.initial = value; gantt.destroy(); gantt = create(); }); addSelect('Align', ALIGNS, state.align, (align) => { state.align = align; gantt.setOptions({ scroll: { align } }); }); addSelect('Behavior', BEHAVIORS, state.behavior, (behavior) => { state.behavior = behavior; gantt.setOptions({ scroll: { behavior } }); });
const marker = document.createElement('input'); marker.type = 'checkbox'; marker.checked = state.today; marker.addEventListener( 'change', () => { state.today = marker.checked; gantt.setOptions({ display: { today: marker.checked } }); }, { signal }, ); const markerLabel = document.createElement('label'); markerLabel.append(marker, ' Today marker'); toolbar.append(markerLabel);
const addButton = (label: string, onClick: () => void): void => { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.addEventListener('click', onClick, { signal }); actions.append(button); }; // The three ways to scroll from code. They use `scroll.align` and `scroll.behavior`, unless the // call gives its own: `gantt.scrollToDate('2026-06-01', { align: 'end', behavior: 'instant' })`. addButton('Today', () => { gantt.scrollToToday(); }); addButton('1 June', () => { gantt.scrollToDate('2026-06-01'); }); addButton('Review', () => { gantt.scrollToTask('review'); }); addButton('Retrospective', () => { gantt.scrollToTask('retro'); });
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 { type Align, ALIGNS, type Behavior, BEHAVIORS, type Initial, INITIALS, options,} from './options';
/** A labelled select of the values it is given. */function Select<T extends string>(props: { name: string; values: readonly T[]; value: T; onChange: (value: T) => void;}) { const { name, values, value, onChange } = props; return ( <label> {name}{' '} <select value={value} onChange={(event) => { const next = values.find((candidate) => candidate === event.target.value); if (next !== undefined) onChange(next); }} > {values.map((candidate) => ( <option key={candidate} value={candidate}> {candidate} </option> ))} </select> </label> );}
export default function Example() { const chart = useRef<GanttHandle>(null); const [initial, setInitial] = useState<Initial>(options.scroll.initial); const [align, setAlign] = useState<Align>(options.scroll.align); const [behavior, setBehavior] = useState<Behavior>(options.scroll.behavior); const [today, setToday] = useState<boolean>(options.display.today); const [status, setStatus] = useState('Scroll the chart, or press a button.'); // 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;
return ( <> <div role="toolbar"> <Select name="Opens at" values={INITIALS} value={initial} onChange={setInitial} /> <Select name="Align" values={ALIGNS} value={align} onChange={setAlign} /> <Select name="Behavior" values={BEHAVIORS} value={behavior} onChange={setBehavior} /> <label> <input type="checkbox" checked={today} onChange={(event) => { setToday(event.target.checked); }} />{' '} Today marker </label> </div> <div role="toolbar"> {/* The three ways to scroll from code. They use `scroll.align` and `scroll.behavior`. */} <button type="button" onClick={() => chart.current?.scrollToToday()}> Today </button> <button type="button" onClick={() => chart.current?.scrollToDate('2026-06-01')}> 1 June </button> <button type="button" onClick={() => chart.current?.scrollToTask('review')}> Review </button> <button type="button" onClick={() => chart.current?.scrollToTask('retro')}> Retrospective </button> </div> <Gantt // `scroll.initial` is read when a chart is created, so a new `key` makes a new chart. key={initial} 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} scroll={{ initial, align, behavior }} display={{ today }} // The chart scrolled, by the user or by a call: where it is, as numbers and as a date. onScroll={({ x, y }) => { const gantt = chart.current; if (gantt) setStatus(describe(gantt.getVisibleRange(), x, y)); }} /> <p>{status}</p> </> );}import type { DateRange } from '@ganttlane/dom';
const day = new Intl.DateTimeFormat('en', { day: 'numeric', month: 'short', year: 'numeric' });
/** * Says what the chart shows: the dates at the left and right edges of the viewport, and where it * is scrolled to. * * @param range - What `getVisibleRange()` returns: `Date`s with the wall-clock time of the chart. * @param x - The horizontal offset `onScroll` reports, in px. * @param y - The vertical offset `onScroll` reports, in px. * @returns A sentence. */export function describe(range: DateRange, x: number, y: number): string { return `Visible: ${day.format(range.start)} to ${day.format(range.end)} (scrolled ${String(Math.round(x))} px across, ${String(Math.round(y))} px down)`;}Files
data.ts: fourteen tasks from February to July, with links.options.ts:scroll, the pinnednow,display.today, and what the selects offer.helpers.ts:describe, which writes the status line.vanilla.ts:scrollTo*,setOptions({ scroll }),on('scroll'), and a new chart whenscroll.initialchanges.react.tsx: the same with props, therefhandle and akey.
Options
| Option | Value | What it does |
|---|---|---|
scroll.initial |
'today' |
Where the chart opens; the select tries each value (read when the chart is created) |
scroll.align |
'center' |
Where the target of a scroll lands in the viewport: 'start', 'center', 'end' or 0 to 1 |
scroll.behavior |
'smooth' |
How scrollTo* moves: 'smooth', 'instant' or 'auto' (the browser’s own) |
display.today |
true |
The today line and marker; the checkbox switches it |
now |
() => '2026-04-15' |
The source of “today” (the system clock by default); pinned so that the demo never changes |
viewMode |
'week' |
The scale of the timeline (React: defaultViewMode, as nothing here changes it) |
onScroll |
a function (vanilla: on('scroll', …)) |
{ x, y, date } on every scroll: the offsets in px and the date at the left edge |
What to try
The chart opens on today (15 April, the red line), centred. Pick another Opens at and the
chart is made again: first-task puts the earliest start in the middle, start and end put
the left or the right end of the timeline at the edge of the chart (whatever the alignment), and the
last choice is a date, 1 June. Then press the buttons and watch the line under the chart: Review scrolls to its
bar and to its row, because scrollToTask moves both axes. Set Align to end and press
Review again: its bar ends at the right edge and its row is the last one in view. Set
Behavior to instant and the scroll jumps instead of gliding. Uncheck Today marker and the
line and the header marker go away.
Notes
scroll.initialis read once, when the chart is created, which is why the select makes a new chart (destroy()andcreateGantt()in the vanilla code, a newkeyin React).align,behavioranddisplay.todayare ordinary options:setOptions({ scroll: { align } })or a prop changes them on the chart that is there.- The values.
'today'is the default.'first-task'is the earliest start (today when there are no tasks).'start'and'end'are the ends of the timeline, padding included.'none'does not scroll. Anything else is a date:'2026-06-01', aDateor epoch milliseconds, aligned byscroll.alignlike today and the first task. - A fixed timeline is clamped. The chart cannot scroll past its ends, so
'today'for a plan that is entirely in the past or the future lands on the nearest end of it. Puttimeline.includeToday: truein the options to make the range of the chart contain today, or turn on the infinite timeline, which grows to reach any date. - A call can bring its own
alignandbehavior, as ingantt.scrollToDate('2026-06-01', { align: 'end', behavior: 'instant' }).scrollToTask(id, { alignY })places the row independently of the bar, and expands the collapsed parents of the row first (expand: falsedoes not). - Calls made too early (before the chart has a width) are remembered, the latest one wins, and
they are performed at mount instead of
scroll.initial. - The dates are wall-clock
Dates.getVisibleRange()and thedateofonScrollareDates that read the same in every time zone, so format them with the local formatter, ashelpers.tsdoes.nowis floating wall-clock time too. - Why
on('scroll')in the vanilla code. The first scroll of a chart, the one that appliesscroll.initial, happens insidecreateGantt, before the function has returned the chart. AnonScrolloption that reads the chart would run too early, so the example adds the listener withgantt.on('scroll', …)once it has the chart. React has no such trouble: the handle in therefis set before the first event.