Custom tooltip
The tooltip of a task is the tooltip option. Its render(context) function returns what to show,
and the type of the answer decides how it is inserted, so that nothing is ever parsed as HTML by
accident:
render returns |
Shown as |
|---|---|
a string |
text |
{ title, subtitle, details, actions } |
three lines of text and buttons that call onSelect(context) |
a DOM Node |
the node, as it is |
{ node, dispose } |
a node that is told (dispose()) when the tooltip is done with it |
{ html } |
markup, parsed as it is: the one way in, and sanitising it is your job |
null, '', whitespace, {} |
nothing: no tooltip |
render runs when its inputs change (another task, new dates while dragging), not on every mouse
move. The context carries the task’s TaskRecord, the preview of a drag in progress, the
predecessors and successors with their lags, the parent, the chart’s locale and api.hide().
With @ganttlane/react the tooltip is a React element, drawn through a portal into the node the
chart shows (renderTooltip). It sees your context providers and your state, and may use hooks.
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';
export const tasks: Task[] = [ { id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-06', progress: 60 }, { id: 'build', name: 'Build', start: '2026-03-09', end: '2026-03-20', progress: 25 }, { id: 'qa', name: 'QA', start: '2026-03-23', end: '2026-03-27' },];
export const links: Link[] = [ { source: 'design', target: 'build' }, { source: 'build', target: 'qa' },];import type { GanttOptions } from '@ganttlane/dom';
export const options = { // The tooltip of a task. Its `render` function is what the example is about: it is in // `vanilla.ts` (`tooltip.render`) and in `react.tsx` (the `renderTooltip` prop). tooltip: { trigger: 'hover', // or 'click': then it stays open, so that its buttons can be reached delay: 100, // ms before a hovered bar shows it placement: 'bottom', // it flips by itself when there is no room }, viewMode: 'week', scroll: { initial: 'first-task' },} satisfies Partial<GanttOptions>;import '@ganttlane/dom/styles.css';
import { createGantt, type TooltipOptions } from '@ganttlane/dom';
import { links, tasks } from './data';import { options } from './options';
type Render = NonNullable<TooltipOptions['render']>;
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 = '380px'; const status = document.createElement('p'); status.textContent = 'Hover a bar. The buttons change what the tooltip is made of.'; root.append(toolbar, chart, status); container.append(root);
// Text sections: `{ title, subtitle, details, actions }` are lines of text and buttons. const sections: Render = ({ task, preview, isDragging, predecessors, locale, api }) => { // While a bar is dragged, show where it would land. const start = preview?.start ?? task.start; const end = preview?.end ?? task.end; if (!start || !end) return null; // no tooltip const day = new Intl.DateTimeFormat(locale, { dateStyle: 'medium' }); // A link record names its tasks by id; `api` reads them. const after = predecessors.map((link) => api.getTask(link.source)?.name).join(', '); return { title: task.name, subtitle: `${day.format(start)} to ${day.format(end)}${isDragging ? ' (moving)' : ''}`, details: [`${Math.round(task.progress)}% done`, after && `after ${after}`] .filter(Boolean) .join(' · '), actions: [ { label: 'Open', onSelect: () => { status.textContent = `Open ${task.name}`; }, }, ], }; };
// A DOM node, for anything the sections cannot say. const node: Render = ({ task }) => { const card = document.createElement('div'); const name = document.createElement('strong'); name.textContent = task.name; // textContent: the name is data, never markup const bar = document.createElement('progress'); bar.max = 100; bar.value = task.progress; card.append(name, bar); return card; };
const gantt = createGantt(chart, { tasks, links, ...options, tooltip: { ...options.tooltip, render: sections }, });
// `tooltip` is taken whole: a new object replaces the one before. const controller = new AbortController(); const choices = [ ['Text sections', sections], ['DOM node', node], ] as const; for (const [label, render] of choices) { const button = document.createElement('button'); button.type = 'button'; button.textContent = label; button.addEventListener( 'click', () => gantt.setOptions({ tooltip: { ...options.tooltip, render } }), { signal: controller.signal }, ); toolbar.append(button); }
return () => { controller.abort(); gantt.destroy(); root.remove(); };}import '@ganttlane/react/styles.css';
import { Gantt, type TaskTooltipContext } from '@ganttlane/react';import { useState } from 'react';
import { links, tasks } from './data';import { options } from './options';
// The tooltip is a component: it sees your context providers and your state, and may use hooks.// Keep the logic here and return `<TaskCard context={context} />` from `renderTooltip`.function TaskCard({ context }: { context: TaskTooltipContext }) { const { task, preview, isDragging, api } = context; // While a bar is dragged, show 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> <strong>{task.name}</strong> <div> {start.toLocaleDateString(context.locale)} to {end.toLocaleDateString(context.locale)} {isDragging ? ' (moving)' : null} </div> <progress max={100} value={task.progress} /> <button type="button" onClick={() => api.hide()}> Close </button> </div> );}
export default function Example() { // A tooltip with a button is easier to reach after a click. const [trigger, setTrigger] = useState<'hover' | 'click'>('click');
return ( <> <div role="toolbar"> <button type="button" onClick={() => setTrigger('hover')}> Hover </button> <button type="button" onClick={() => setTrigger('click')}> Click </button> </div> <Gantt style={{ height: 380 }} defaultTasks={tasks} defaultLinks={links} {...options} tooltip={{ ...options.tooltip, trigger }} renderTooltip={(context) => <TaskCard context={context} />} /> <p>The tooltip opens on a {trigger}.</p> </> );}Files
data.ts: three tasks, two links.options.ts:tooltip.trigger,delayandplacement, and the first scroll.vanilla.ts: twotooltip.renderfunctions (text sections, a DOM node) and the buttons that swap them withsetOptions.react.tsx: aTaskCardcomponent returned fromrenderTooltip.
Options
| Option | Value | What it does |
|---|---|---|
tooltip.trigger |
'hover' |
What opens it; 'click' keeps it open, so its buttons can be reached |
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: text sections or a DOM node |
renderTooltip |
a function (React) | Returns a ReactNode; replaces tooltip.render when both are given |
What to try
Hover a bar (click it in the React example, which has a button in its tooltip), then drag it: the
tooltip shows where it would land. The vanilla buttons swap the tooltip between text sections and a
DOM node. A tooltip with actions stays open until a press outside it or Escape, so its buttons
can be used.
Notes
tooltipis taken whole bysetOptions: a given group replaces the one before, so a field it leaves out is back to its default. That is why the buttons spreadoptions.tooltip.- A DOM node is built with
textContent, neverinnerHTML: the name of a task is data. renderLinkMenu(the menu a click on an arrow opens) andrenderBarLabel(the text on a bar) work the same way in React. A tooltip, menu or label that renders to nothing (null,false, an empty element) is not shown.