Link types and lags
A link joins an edge of one task (the source) to an edge of another (the target) and keeps them
lag apart. There are four types, one for each pairing of edges. Here one task, Design, is the
source of nine links: two of each type, one with a lag (a gap, positive) and one with a lead (a
negative lag, an overlap), and one link that names neither a type nor a lag. Drag Design and
every target moves with it, each by its own rule.
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';
// One source, Design (Monday 2026-03-09 to Thursday 2026-03-12), and nine tasks linked to it: two for// each link type, one with a lag (a gap after the anchor, positive) and one with a lead (a// negative lag: the anchors overlap), and one whose link names neither a type nor a lag.//// A link joins an edge of the source to an edge of the target, `lag` apart:// FS the end of the source to the start of the target// SS the start of the source to the start of the target// FF the end of the source to the end of the target// SF the start of the source to the end of the target// A task with an `end` ends on that day (inclusive); a task with a `duration` lasts that many days.export const tasks: Task[] = [ { id: 'design', name: 'Design', start: '2026-03-09', end: '2026-03-12' },
// FS: starts 2 days after Design ends, and 2 days before it ends. { id: 'build', name: 'Build · FS +2d', start: '2026-03-15', duration: 3 }, { id: 'docs', name: 'Docs · FS -2d', start: '2026-03-11', duration: 3 },
// SS: starts 2 days after Design starts, and 2 days before it starts. { id: 'frontend', name: 'Frontend · SS +2d', start: '2026-03-11', duration: 3 }, { id: 'qa', name: 'QA · SS -2d', start: '2026-03-07', duration: 3 },
// FF: ends 2 days after Design ends, and 2 days before it ends. { id: 'marketing', name: 'Marketing · FF +2d', start: '2026-03-11', end: '2026-03-14' }, { id: 'release', name: 'Release · FF -2d', start: '2026-03-07', end: '2026-03-10' },
// SF: ends 2 days after Design starts, and 2 days before it starts. { id: 'hiring', name: 'Hiring · SF +2d', start: '2026-03-07', end: '2026-03-10' }, { id: 'support', name: 'Support · SF -2d', start: '2026-03-04', end: '2026-03-06' },
// No type and no lag: the type is `scheduling.defaultLinkType`, the lag the gap it has now. { id: 'review', name: 'Review · no type', start: '2026-03-15', duration: 3 },];
export const links: Link[] = [ { source: 'design', target: 'build', type: 'FS', lag: 2 }, { source: 'design', target: 'docs', type: 'FS', lag: -2 }, { source: 'design', target: 'frontend', type: 'SS', lag: 2 }, { source: 'design', target: 'qa', type: 'SS', lag: -2 }, { source: 'design', target: 'marketing', type: 'FF', lag: 2 }, { source: 'design', target: 'release', type: 'FF', lag: -2 }, { source: 'design', target: 'hiring', type: 'SF', lag: 2 }, { source: 'design', target: 'support', type: 'SF', lag: -2 }, { source: 'design', target: 'review' },];import type { GanttOptions } from '@ganttlane/dom';
export const options = { // Every day is a working day, so no weekend fill and no weekend conflicts: here the only thing // that moves a bar is a link. calendar: { weekends: [] }, scheduling: { // The type of a link that does not name one: 'FS' (the default), 'SS', 'FF' or 'SF'. It is // read when the data is loaded and again whenever it changes, so the select above the chart // can change it without recreating the chart. defaultLinkType: 'FS', }, display: { // The largest radius, in px, of the rounded corners of a link's route. The default is 5; // 0 draws square corners. linkCurveRadius: 10, }, // Compact rows, so that all ten show. layout: { rowHeight: 36, barHeight: 22 }, // Open on the plan; the default, 'today', is far from these dates. scroll: { initial: 'first-task' },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';
import { createGantt, type Gantt } from '@ganttlane/dom';
import { links, tasks } from './data';import { describe, LINK_TYPES, RADII } from './helpers';import { options } 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 = '440px'; const status = document.createElement('p'); status.textContent = 'Drag Design, or one of its edges, and watch the nine tasks linked to it.'; root.append(toolbar, chart, status); container.append(root);
// The handler reads `gantt`, which it is passed to: give it a return type. const gantt: Gantt = createGantt(chart, { tasks, links, ...options, // One event per drag: the dragged task first, then the tasks its links moved. onTasksChange: (changeSet): void => { status.textContent = describe(changeSet, (id) => gantt.getTask(id)?.name ?? String(id)); }, });
const controller = new AbortController(); const { signal } = controller;
const addSelect = ( label: string, values: readonly (string | number)[], selected: string | number, onChange: (value: string) => void, ): void => { const field = document.createElement('label'); const select = document.createElement('select'); for (const value of values) { const option = document.createElement('option'); option.value = String(value); option.textContent = String(value); option.selected = value === selected; select.append(option); } select.addEventListener('change', () => onChange(select.value), { signal }); field.append(`${label} `, select); toolbar.append(field); };
// Both options change without recreating the chart: the core re-reads the link types, and the // renderer redraws the routes. addSelect('Default link type', LINK_TYPES, options.scheduling.defaultLinkType, (value) => { const type = LINK_TYPES.find((candidate) => candidate === value); if (type) gantt.setOptions({ scheduling: { defaultLinkType: type } }); }); addSelect('Corner radius', RADII, options.display.linkCurveRadius, (value) => { gantt.setOptions({ display: { linkCurveRadius: Number(value) } }); });
const reset = document.createElement('button'); reset.type = 'button'; reset.textContent = 'Reset'; reset.addEventListener( 'click', () => { gantt.setTasks(tasks); gantt.setLinks(links); status.textContent = 'Back to the start.'; }, { signal }, ); toolbar.append(reset);
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, LINK_TYPES, type LinkType, RADII } from './helpers';import { options } from './options';
export default function Example() { const chart = useRef<GanttHandle>(null); const [defaultLinkType, setDefaultLinkType] = useState<LinkType>( options.scheduling.defaultLinkType, ); const [radius, setRadius] = useState(options.display.linkCurveRadius); const [status, setStatus] = useState( 'Drag Design, or one of its edges, and watch the nine tasks linked to it.', );
return ( <> <div role="toolbar"> <label> Default link type{' '} <select value={defaultLinkType} onChange={(event) => { const type = LINK_TYPES.find((candidate) => candidate === event.target.value); if (type) setDefaultLinkType(type); }} > {LINK_TYPES.map((type) => ( <option key={type}>{type}</option> ))} </select> </label> <label> Corner radius{' '} <select value={radius} onChange={(event) => setRadius(Number(event.target.value))}> {RADII.map((value) => ( <option key={value}>{value}</option> ))} </select> </label> <button type="button" onClick={() => { chart.current?.setTasks(tasks); chart.current?.setLinks(links); setStatus('Back to the start.'); }} > Reset </button> </div> <Gantt ref={chart} // The chart fills its container and scrolls inside it, so the container needs a height. style={{ height: 440 }} // The chart owns the data (uncontrolled): these are where it starts. defaultTasks={tasks} defaultLinks={links} {...options} // In React the props are the truth: a value changed with `setOptions` on the ref would be // overwritten by the next render. So the two options the selects change are passed here, // from state, after the spread. scheduling={{ ...options.scheduling, defaultLinkType }} display={{ ...options.display, linkCurveRadius: radius }} // One event per drag: the dragged task first, then the tasks its links moved. onTasksChange={(changeSet) => { setStatus(describe(changeSet, (id) => chart.current?.getTask(id)?.name ?? String(id))); }} /> <p>{status}</p> </> );}import type { Change, ChangeSet, TaskId } from '@ganttlane/core';
/** The four link types, and the corner radii (px) the example offers. */export const LINK_TYPES = ['FS', 'SS', 'FF', 'SF'] as const;export type LinkType = (typeof LINK_TYPES)[number];export const RADII = [0, 5, 10, 20];
const DAY = 24 * 60 * 60 * 1000;
/** A length of time in days with its sign: `+2d`, `-1d`. */function signedDays(ms: number): string { const days = ms / DAY; return `${days > 0 ? '+' : ''}${String(days)}d`;}
/** How far a change moved a task: one number for a move, the edges that moved for a resize. */function shift(change: Change): string { const start = change.after.start - change.before.start; const end = change.after.endExclusive - change.before.endExclusive; if (start === end) return signedDays(start); const edges = []; if (start !== 0) edges.push(`start ${signedDays(start)}`); if (end !== 0) edges.push(`end ${signedDays(end)}`); return edges.join(', ');}
/** * Says what a drag changed: the dragged task, the tasks its links moved with it (grouped by how * far they moved), and the conflicts the result has (a link whose gap is below its lag). * * @param changeSet - What `onTasksChange` reports. Its dates are in core's floating milliseconds. * @param nameOf - The name of a task, from the chart. * @returns A sentence for the status line. */export function describe(changeSet: ChangeSet, nameOf: (id: TaskId) => string): string { const [dragged, ...followers] = changeSet.changes; if (!dragged) return 'Nothing changed.'; let text = `${changeSet.reason}: ${nameOf(dragged.id)} (${shift(dragged)}).`; // The tasks that followed, by how far each moved. const groups = new Map<string, string[]>(); for (const change of followers) { const names = groups.get(shift(change)) ?? []; names.push(nameOf(change.id)); groups.set(shift(change), names); } for (const [how, names] of groups) { text += names.length > 3 ? ` ${String(names.length)} followed (${how}).` : ` ${names.join(', ')} (${how}).`; } if (changeSet.conflicts.length > 0) { text += ` Conflict: ${changeSet.conflicts.map((conflict) => conflict.message).join('; ')}.`; } return text;}Files
data.ts: Design, its nine targets and the links, with the dates worked out in comments.options.ts: the calendar,scheduling.defaultLinkType,display.linkCurveRadius.helpers.ts:describe, which turns theChangeSetof a drag into a sentence.vanilla.tsandreact.tsx: the chart, two selects and a reset button.
Options
| Option | Value | What it does |
|---|---|---|
calendar.weekends |
[] |
Every day is a working day: no weekend fill, so only the links move the bars |
scheduling.defaultLinkType |
'FS' |
The type of a link that names none (the select changes it: setOptions) |
display.linkCurveRadius |
10 |
The largest radius, in px, of the corners of a link’s route (default 5) |
layout.rowHeight, barHeight |
36, 22 |
Compact rows, so that all ten show |
scroll.initial |
'first-task' |
Opens on the plan; the default, 'today', is far from these dates |
onTasksChange |
a function (in the example) | One ChangeSet per drag, with the dragged task and every task it moved |
link.type, link.lag |
'FS', 2, -2, … |
Per link: which edges, and how far apart (a number is days; a string such as '4h' is a duration) |
What to try
- Drag Design to the right. All nine targets move by the same number of days: a link keeps its lag whichever way the source goes. The status line lists them in the order the chart moved them.
- Drag the right edge of Design. Only the targets that hang from its end move: the FS ones (their start) and the FF ones (their end). The SS and SF ones stay, because they hang from its start.
- Drag the left edge of Design. Now the SS and SF targets move, and the FS and FF ones stay. A link on an edge that did not move does not move its target.
- Drag a target (say Build) closer to Design than its lag allows. A target is free to go anywhere: nothing pushes it back, but the link turns red and the status line reports the conflict. Drag Design again and the link puts Build back exactly where its lag says.
- Change the default link type. Review has a link with no
typeand nolag: it takes the type from the select, and the lag from the gap its two edges have now, so the arrow is redrawn and nothing moves until you drag. - Change the corner radius and watch the routes.
Notes
- The four types.
FSjoins the end of the source to the start of the target,SSthe start to the start,FFthe end to the end,SFthe start of the source to the end of the target. - The lag is a gap between those two edges, counted in the target’s
durationMode(calendar days here). A negative lag is a lead: the target’s edge lies before the source’s. A number is days; a string is a duration ('3d','4h'). - A lag is a rule, not a minimum. When the source moves, the target goes to exactly
lagfrom it, in both directions. A link with nolagkeeps the gap it has when the data is loaded. - A link to an edge that does not move moves nothing. Resizing the start of the source leaves
the targets of its
FSandFFlinks alone. - Two links to the same target pin both of its edges. An
SSand anFFlink make the target stretch when the source is resized. AnFSand anFFlink both hang from the source’s end, so with no lags (each keeps the gap it has) the target moves with it and keeps its length. A child pinned to both edges of its phase (['SS', 'FF'], withfollowParent) stretches the same way, see Groups and children. - React.
schedulinganddisplayare passed to<Gantt>after the spread ofoptions, from state: in React the props are the truth, so an option changed withsetOptionson the ref would be overwritten by the next render.defaultTasksanddefaultLinksare where the chart starts, andchart.current.setTasks(tasks)puts the data back.