Link gaps in a tooltip
The tooltip of a task lists its predecessors, one row per link, with how far the task sits from each:
Design: 3 days after, Backend: 1 day before, Build: no gap. A task counted in working days
says so (Review: 2 working days after), and the weekend in between does not count.
The numbers are the distance between the bars as they are drawn, also while a bar is being dragged.
pnpm add @ganttlane/dom @ganttlane/core # vanillapnpm add @ganttlane/react @ganttlane/core react react-dom # Reactnpm install @ganttlane/dom @ganttlane/core # vanillanpm install @ganttlane/react @ganttlane/core react react-dom # Reactyarn add @ganttlane/dom @ganttlane/core # vanillayarn add @ganttlane/react @ganttlane/core react react-dom # Reactbun add @ganttlane/dom @ganttlane/core # vanillabun add @ganttlane/react @ganttlane/core react react-dom # Reactimport type { Link, Task } from '@ganttlane/core';
// 2 March 2026 is a Monday. An `end` is the last day of the task, so a bar ends at the midnight// after it: Design ends on Saturday 7 March at 00:00.export const tasks: Task[] = [ { id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-06', progress: 100 }, { id: 'backend', name: 'Backend', start: '2026-03-02', end: '2026-03-10', progress: 70 }, // Two predecessors: 3 days after the end of Design, and 1 day before the end of Backend. { id: 'build', name: 'Build', start: '2026-03-10', end: '2026-03-12', progress: 20 }, // Starts the moment Build ends: no gap. { id: 'review', name: 'Review', start: '2026-03-13', end: '2026-03-18' }, // Start to start, with a lag of its own: 3 days after the start of Review. { id: 'docs', name: 'Docs', start: '2026-03-16', end: '2026-03-19' }, // Counted in working days, so the weekend before it does not count: 2 after Review, 1 after Docs. { id: 'launch', name: 'Launch', start: '2026-03-23', end: '2026-03-24', durationMode: 'working', },];
export const links: Link[] = [ { source: 'design', target: 'build' }, { source: 'backend', target: 'build' }, { source: 'build', target: 'review' }, { source: 'review', target: 'docs', type: 'SS', lag: 3 }, { source: 'review', target: 'launch' }, { source: 'docs', target: 'launch' },];import type { GanttOptions } from '@ganttlane/dom';
export const options = { // The tooltip of a task. Its `render` function is in `vanilla.ts` (`tooltip.render`) and in // `react.tsx` (the `renderTooltip` prop); what it says comes from `helpers.ts`. tooltip: { delay: 100, // ms before a hovered bar shows it placement: 'bottom', // it flips by itself when there is no room }, scheduling: { // What an edit does to the tasks after it: // 'preserve-lag' they move too, so that every link keeps its lag (default); // 'none' only the task you edit moves, and the gaps change. // The select above the chart changes it with `setOptions`. propagation: 'preserve-lag', }, // One column a week, a day is 20 px and a drag snaps to days: the whole plan fits. viewMode: 'week', scroll: { initial: 'first-task' },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';import './styles.css';
import { createGantt, type Gantt, type TooltipOptions } from '@ganttlane/dom';
import { links, tasks } from './data';import { datesText, gapRows, PROPAGATION } from './helpers';import { options } from './options';
type Render = NonNullable<TooltipOptions['render']>;
/** An element with a class and a text: the text is data, so `textContent`, never `innerHTML`. */function element(tag: string, className: string, text: string): HTMLElement { const node = document.createElement(tag); node.className = className; node.textContent = text; return node;}
export function mount(container: HTMLElement): () => void { const root = document.createElement('div'); const toolbar = document.createElement('div'); toolbar.setAttribute('role', 'toolbar'); const chart = document.createElement('div'); chart.style.height = '390px'; const status = document.createElement('p'); status.textContent = 'Hover Build, then drag it: the rows follow the bar.'; root.append(toolbar, chart, status); container.append(root);
// The rows of the tooltip are measured from the chart's core: the context of `render` says which // links there are, and the core where their bars are now. const render: Render = ({ task, preview, isDragging, predecessors, locale }) => { // While a bar is dragged, `preview` says where it would land. const start = preview?.start ?? task.start; const end = preview?.end ?? task.end; if (!start || !end) return null; // no tooltip const card = element('div', 'gap-card', ''); const dates = `${datesText(start, end, locale)}${isDragging ? ' (moving)' : ''}`; card.append(element('strong', '', task.name), element('div', 'gap-card-note', dates)); const rows = gapRows(task.id, predecessors, gantt.core); if (rows.length === 0) card.append(element('div', 'gap-card-note', 'No predecessors')); else { const list = element('ul', 'gap-card-rows', ''); for (const { name, gap, type } of rows) { const row = document.createElement('li'); row.append( element('span', 'gap-card-name', `${name}:`), element('span', '', gap), element('span', 'gap-card-type', type), ); list.append(row); } card.append(list); } return card; };
const gantt: Gantt = createGantt(chart, { tasks, links, ...options, tooltip: { ...options.tooltip, render }, });
const controller = new AbortController(); const label = document.createElement('label'); label.textContent = 'Propagation '; const policy = document.createElement('select'); for (const value of PROPAGATION) { const option = document.createElement('option'); option.value = option.textContent = value; policy.append(option); } policy.addEventListener( 'change', () => { const propagation = policy.value as (typeof PROPAGATION)[number]; gantt.setOptions({ scheduling: { propagation } }); }, { signal: controller.signal }, ); label.append(policy); toolbar.append(label);
return () => { controller.abort(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';import './styles.css';
import { Gantt, type GanttHandle, type TaskTooltipContext } from '@ganttlane/react';import { useRef, useState } from 'react';
import { links, tasks } from './data';import { datesText, type GapRow, gapRows, PROPAGATION } from './helpers';import { options } from './options';
function GapCard({ context, rows }: { context: TaskTooltipContext; rows: readonly GapRow[] }) { const { task, preview, isDragging, locale } = context; // While a bar is dragged, `preview` says where it would land. const start = preview?.start ?? task.start; const end = preview?.end ?? task.end; if (!start || !end) return null; // an empty tooltip is not shown return ( <div className="gap-card"> <strong>{task.name}</strong> <div className="gap-card-note"> {datesText(start, end, locale)} {isDragging ? ' (moving)' : null} </div> {rows.length === 0 ? ( <div className="gap-card-note">No predecessors</div> ) : ( <ul className="gap-card-rows"> {rows.map(({ id, name, gap, type }) => ( <li key={id}> <span className="gap-card-name">{name}:</span> <span>{gap}</span> <span className="gap-card-type">{type}</span> </li> ))} </ul> )} </div> );}
export default function Example() { const chart = useRef<GanttHandle>(null); const [propagation, setPropagation] = useState<(typeof PROPAGATION)[number]>( options.scheduling.propagation, );
return ( <> <div role="toolbar"> <label> Propagation{' '} <select value={propagation} onChange={(event) => setPropagation(event.target.value as typeof propagation)} > {PROPAGATION.map((value) => ( <option key={value}>{value}</option> ))} </select> </label> </div> <Gantt ref={chart} style={{ height: 390 }} defaultTasks={tasks} defaultLinks={links} {...options} scheduling={{ propagation }} // The rows are measured from the chart's core: the context says which links there are, and // the core where their bars are now. renderTooltip={(context) => ( <GapCard context={context} rows={ chart.current ? gapRows(context.task.id, context.predecessors, chart.current.core) : [] } /> )} /> <p>Hover Build, then drag it: the rows follow the bar.</p> </> );}import { type ActiveLinkRecord, anchorTime, type DurationMode, type GanttCore, linkAnchors, type LinkType, measureLag, selectTaskPatch, type TaskId,} from '@ganttlane/core';
const HOUR = 3_600_000;const DAY = 24 * HOUR;
/** The two policies of `scheduling.propagation`, for the select above the chart. */export const PROPAGATION = ['preserve-lag', 'none'] as const;
/** One row of the tooltip: a link into the task, and how far the task sits from its source. */export interface GapRow { /** The id of the link, a key for a list. */ readonly id: string; /** The name of the predecessor. */ readonly name: string; /** The distance in words: `3 days after`, `1 working day before`, `no gap`. */ readonly gap: string; /** The type of the link, which says which edges of the two bars the distance is between. */ readonly type: LinkType;}
/** * A distance in words. It is a multiple of the granularity: whole days in this plan, hours in a * plan with a finer one. * * @param ms - The distance, negative when the task lies before the predecessor. * @param mode - How it was counted: the `durationMode` of the task. * @returns For example `'3 days after'`, `'1 working day before'`, `'no gap'`. */function gapText(ms: number, mode: DurationMode): string { const working = mode === 'working'; if (ms === 0) return working ? 'no working days between' : 'no gap'; const size = Math.abs(ms); const [count, unit] = size % DAY === 0 ? [size / DAY, 'day'] : [size / HOUR, 'hour']; const plural = count === 1 ? '' : 's'; return `${String(count)} ${working ? 'working ' : ''}${unit}${plural} ${ms > 0 ? 'after' : 'before'}`;}
/** * Measures the links into a task between the bars as they are drawn now. `lag` on a link record * cannot say that: the record is the committed one, so it stays where it was during a drag, and an * explicit `Link.lag` is what the link asks for, whether the bars keep to it or not. * * `selectTaskPatch` reads a task's dates with the drag in progress over the committed data, and the * rest is what the chart measures with: the anchors of the link's type, the `durationMode` of the * task, the chart's calendar and granularity. * * @param id - The task. * @param predecessors - The links into it (`context.predecessors`). * @param core - The chart's core (`gantt.core`, or `core` on the ref of `<Gantt>`). * @returns One row per link, in the order of the links. */export function gapRows( id: TaskId, predecessors: readonly ActiveLinkRecord[], core: GanttCore,): GapRow[] { const { state } = core.getSnapshot(); const task = selectTaskPatch(state, id); if (task === undefined) return []; return predecessors.flatMap((link) => { const source = selectTaskPatch(state, link.source); if (source === undefined) return []; const anchors = linkAnchors(link.type); const ms = measureLag( anchorTime(source, anchors.source), anchorTime(task, anchors.target), link.lag.mode, state.derived.model.options, ); return [ { id: link.id, name: core.getTask(link.source)?.name ?? String(link.source), gap: gapText(ms, link.lag.mode), type: link.type, }, ]; });}
/** The dates of a task in words: `Mar 10, 2026 to Mar 12, 2026`. */export function datesText(start: Date, end: Date, locale: string): string { const day = new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }); return `${day.format(start)} to ${day.format(end)}`;}/* * The tooltip of the example: your own markup, so your own styles. Colours come from the text * colour of the tooltip, so that it fits a light and a dark chart alike. */.gap-card { display: grid; gap: 0.25rem; font-size: 0.8125rem; line-height: 1.4;}
.gap-card-note,.gap-card-type { color: color-mix(in srgb, currentColor 70%, transparent);}
.gap-card-rows { display: grid; grid-template-columns: repeat(3, max-content); justify-content: start; gap: 0.125rem 0.5rem; margin: 0.125rem 0 0; padding: 0; list-style: none;}
/* A row is three cells of the grid above, so that the columns line up. */.gap-card-rows li { display: contents;}
.gap-card-name { font-weight: 600;}Files
data.ts: six tasks and six links, with a task that has two predecessors and each kind of row: after, before, no gap, start to start, working days.options.ts:tooltip.delayandplacement,scheduling.propagation, the first scroll.helpers.ts:gapRows, which measures the links and puts each distance in words. The vanilla and the React file both draw what it returns.styles.css: the tooltip’s own markup, its own styles.vanilla.ts: atooltip.renderthat returns a DOM node, and a select for the propagation.react.tsx: aGapCardcomponent returned fromrenderTooltip, and the same select.
Options
| Option | Value | What it does |
|---|---|---|
tooltip.delay |
100 |
Milliseconds a hovered bar waits before it shows the tooltip |
tooltip.placement |
'bottom' |
The preferred side of the bar; it flips by itself when there is no room |
tooltip.render |
a function (vanilla) | Returns the content: a DOM node here |
renderTooltip |
a function (React) | Returns a ReactNode; replaces tooltip.render when both are given |
scheduling.propagation |
'preserve-lag' |
'none' moves only the task you edit, so that dragging a predecessor changes a gap |
task.durationMode |
'working' (Launch) |
Per task: its lengths and the gaps of the links into it count working time |
link.type, link.lag |
'SS', 3 (Docs) |
Per link: which edges the gap is between, and the gap the link asks for |
What to try
- Hover Build. It has two predecessors: it starts 3 days after the end of Design, and 1 day before the end of Backend.
- Drag Build to the right. The dates and both rows change with every day it crosses: the
Backend row goes from
1 day beforethroughno gapto1 day after. - Drag an edge of Build. The left edge changes the rows, because these links end at its start. The right edge changes the dates and leaves the rows.
- Drag Docs one day to the left. A drag does not land on a weekend here, so it goes to the
Friday. That is less than the 3 days its link asks for, so the link turns red, and the row says
no gap: how far the two tasks really are, while the lag of the link still says 3. - Hover Launch. On the screen it starts 4 days after the end of Review, and the row says 2
working days: the weekend does not count. Drag it to the Friday before, and the Docs row says
no working days between. - Drag Design, with Propagation at
preserve-lagand then atnone. Its tooltip has no rows, as it has no predecessors. Hover Build after each drop. Withpreserve-lag, Build moved along, so its row for Design stays and the one for Backend changes. Withnone, it is the other way around.
What the numbers mean
A row is measureLag from the edge of the predecessor to the edge of the task that the type of the
link names (linkAnchors, anchorTime), counted in the task’s durationMode with the chart’s
calendar and granularity. That is how the chart itself decides whether a link is broken.
The dates of both bars come from selectTaskPatch(snapshot.state, id): the drag in progress over the
committed data. So the numbers during a drag are what the drop would leave, and they stay right for a
predecessor that moves along in the same drag (a child of the dragged task, say).
The type at the end of a row says which edges those are. FS is the end of the predecessor to the
start of the task, SS start to start, FF end to end, SF start to end.
Notes
context.predecessors[n].laglooks like the answer, and is not. The record comes from the committed link graph, so it stays where it was during a drag, whilepreviewin the same context moves. And an explicitLink.lagis what the link asks for, not what the bars do: after a drop that breaks the link, or withpropagation: 'none', the two differ. Read it for the gap a link keeps; measure for the gap there is.- The context of
renderhas no calendar, granularity or draft, so the function reads them from the chart:gantt.core.getSnapshot()in vanilla, thecoreof the<Gantt>ref in React. The vanillarendercloses overgantt, the React one over the ref; both run only after the chart exists. renderis asked again when the task’s own drag moves or the data changes, not when another task is dragged. With the defaultshowWhileDraggingthat never shows: a drag brings up the tooltip of the dragged task. A click tooltip kept open withtrigger: 'click'andshowWhileDragging: falsekeeps the rows it has while another task is dragged, and has the new ones at the drop.- Measure
successorsin the context the same way, from this task to the other. - The React card takes its rows from the
renderTooltipcallback, which the chart calls with each change. It is a plain component, and is drawn from what it is given.