Slots as React
React only. Three places of the chart have content of their own: the tooltip of a task, the menu a
click on an arrow opens, and the label of a bar. renderTooltip, renderLinkMenu and
renderBarLabel give each of them as React: a function from the chart’s context to a ReactNode,
which React draws through a portal into the node the chart shows. The portals sit next to the chart,
in your tree, so the content reads your context providers and your state, and may use hooks.
Here the people come from a context provider (a directory of who is who): the labels of the bars
show the owner’s initials, the tooltip the name and the role, and the menu of an arrow who hands
over to whom. The tasks carry only an owner id, in a typed task.data.
pnpm add @ganttlane/react react react-domnpm install @ganttlane/react react react-domyarn add @ganttlane/react react react-dombun add @ganttlane/react react react-domimport type { Link, Task } from '@ganttlane/core';
/** Who owns a task. The chart does not know people: this is the application's own id. */export type PersonId = 'ann' | 'bo' | 'cy';
/** What `task.data` holds. It is the type of `record.task.data` in every slot. */export interface Meta { owner: PersonId;}
// 2026-03-02 is a Monday.export const tasks: Task<Meta>[] = [ { id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-06', progress: 100, data: { owner: 'ann' }, }, { id: 'backend', name: 'Backend', start: '2026-03-09', end: '2026-03-18', progress: 55, data: { owner: 'bo' }, }, { id: 'frontend', name: 'Frontend', start: '2026-03-09', end: '2026-03-20', progress: 35, data: { owner: 'cy' }, }, { id: 'docs', name: 'Docs', start: '2026-03-12', end: '2026-03-18', data: { owner: 'ann' } }, { id: 'qa', name: 'QA', start: '2026-03-23', end: '2026-03-26', data: { owner: 'cy' } }, { id: 'release', name: 'Release', start: '2026-03-27', end: '2026-03-31', data: { owner: 'bo' } },];
export const links: Link[] = [ { source: 'design', target: 'backend' }, { source: 'design', target: 'frontend' }, { source: 'backend', target: 'qa' }, { source: 'frontend', target: 'qa' }, { source: 'docs', target: 'qa' }, { source: 'qa', target: 'release' },];import type { GanttOptions } from '@ganttlane/dom';
import type { Meta } from './data';
export const options = { // The scale of the timeline. (<Gantt> takes it as `viewMode`, controlled, or as // `defaultViewMode`, which the chart then owns: see `react.tsx`.) viewMode: 'week', // Open on the plan. The default, 'today', is far from these dates. scroll: { initial: 'first-task' }, // A click opens the tooltip and keeps it open, so that the buttons in it can be pressed. The // default, 'hover', is for reading: a hover tooltip takes no pointer events. tooltip: { trigger: 'click' }, // The chart does not measure what React draws, so it cannot tell if a React label fits in its // bar: 'auto' would always put it inside. 'inside' says so; 'outside' is the other choice. display: { barLabel: 'inside' },} satisfies Partial<GanttOptions<Meta>>;import '@ganttlane/react/styles.css';import './styles.css';
import { Gantt, type GanttHandle } from '@ganttlane/react';import { useRef, useState } from 'react';
import { links, type Meta, tasks } from './data';import { DirectoryContext, TEAMS } from './directory';import { options } from './options';import { BarLabel, BrokenTooltip, CompactTooltip, FullTooltip, HandoffMenu } from './slots';
// Which function fills the tooltip slot. Each one has its own `slotKey` (see `slots.tsx`).const TOOLTIPS = { full: FullTooltip, compact: CompactTooltip, throws: BrokenTooltip };
export default function Example() { // `Meta` is the type of `task.data`: the handle and every slot know it. const chart = useRef<GanttHandle<Meta>>(null); const [team, setTeam] = useState<keyof typeof TEAMS>('a'); const [tooltip, setTooltip] = useState<keyof typeof TOOLTIPS>('full'); const [status, setStatus] = useState('Click a bar for its tooltip, an arrow for its menu.');
// `viewMode` is a controlled prop in React (the chart shows the one your state holds), and // nothing here changes it, so the chart starts from `defaultViewMode` and owns the mode; // `defaultTasks` and `defaultLinks` are where it starts with the data. const { viewMode, ...rest } = options;
return ( // The provider is above `<Gantt>`: the slots are drawn in its tree, so they read it. <DirectoryContext.Provider value={TEAMS[team]}> <div role="toolbar" aria-label="Slot options"> <label> Team{' '} <select value={team} onChange={(event) => { setTeam(event.target.value === 'b' ? 'b' : 'a'); }} > <option value="a">A</option> <option value="b">B</option> </select> </label> <label> Tooltip{' '} <select value={tooltip} onChange={(event) => { const next = event.target.value; setTooltip(next === 'compact' || next === 'throws' ? next : 'full'); }} > <option value="full">full</option> <option value="compact">compact</option> <option value="throws">throws</option> </select> </label> <button type="button" onClick={() => { // Links that were removed from the menu: the handle puts the list back. chart.current?.setLinks(links); setStatus('The links are back.'); }} > Restore links </button> </div> <Gantt ref={chart} // The chart fills its container and scrolls inside it, so the container needs a height. style={{ height: 380 }} defaultTasks={tasks} defaultLinks={links} defaultViewMode={viewMode} {...rest} // The slots: each returns a `ReactNode`, drawn through a portal into the tooltip, the menu // and the bars. They replace `tooltip.render`, `linkMenu.render` and `display.renderBarLabel`. renderTooltip={TOOLTIPS[tooltip]} renderLinkMenu={(context) => <HandoffMenu context={context} />} renderBarLabel={(context) => <BarLabel context={context} />} // What a slot throws while React renders it is reported here, with the name of the prop. onError={({ error, name }) => { setStatus(`onError (${name}): ${String(error)}`); }} // `remove()` in the menu goes through the chart, and the chart says so. onLinkDelete={(link) => { setStatus(`onLinkDelete: ${String(link.source)} to ${String(link.target)} removed.`); }} /> <p>{status}</p> </DirectoryContext.Provider> );}import { createContext } from 'react';
import type { PersonId } from './data';
export interface Person { name: string; role: string; initials: string;}
export type Directory = Record<PersonId, Person>;
/** Two teams: the same three ids, other people, so that a change of the provider is visible. */export const TEAMS = { a: { ann: { name: 'Ann Lee', role: 'Designer', initials: 'AL' }, bo: { name: 'Bo Chen', role: 'Backend engineer', initials: 'BC' }, cy: { name: 'Cy Park', role: 'Frontend engineer', initials: 'CP' }, }, b: { ann: { name: 'Dee Ross', role: 'Design lead', initials: 'DR' }, bo: { name: 'Eli Stone', role: 'Platform engineer', initials: 'ES' }, cy: { name: 'Fay Moss', role: 'QA engineer', initials: 'FM' }, },} satisfies Record<string, Directory>;
/** * The application's own context: who the people are. The chart knows nothing about it, and the * slots read it, because React renders their content in the tree below the provider. */export const DirectoryContext = createContext<Directory>(TEAMS.a);import type { BarLabelContext, LinkMenuContext, TaskTooltipContext } from '@ganttlane/react';import { useContext, useState } from 'react';
import type { Meta } from './data';import { DirectoryContext } from './directory';
/** The person who owns a task: `data` is typed (`Meta`), the people come from the context. */function useOwner(data: Meta | undefined) { const directory = useContext(DirectoryContext); return data && directory[data.owner];}
// ---- The tooltip: three functions, one of which is picked at a time ----//// A function given as `renderTooltip` is called as the body of a component, once for the tooltip// the chart is showing. So it may call hooks (`useContext`, `useState`), and they keep their// state while the chart makes the tooltip again for the same task (a drag preview, a new// progress). The chart is not told when the function is replaced.//// These three have different hooks, so each one says it is a component of its own (`slotKey`):// switching from one to another starts the new one anew, and React has nothing to complain about.
/** Hooks: the context, and a state of its own (the watch button). */export function FullTooltip(context: TaskTooltipContext<Meta>) { const { task, preview, isDragging, predecessors, api } = context; const owner = useOwner(task.task.data); const [watching, setWatching] = useState(false); const day = new Intl.DateTimeFormat(context.locale, { day: 'numeric', month: 'short' }); // While a bar is dragged, `preview` says where it would land. const start = preview?.start ?? task.start; const end = preview?.end ?? task.end; const after = predecessors.flatMap((link) => api.getTask(link.source)?.name ?? []).join(', ');
return ( <div className="slots-card"> <strong>{task.name}</strong> <span>{owner ? `${owner.name}, ${owner.role}` : 'Unassigned'}</span> {start && end ? ( <span> {day.format(start)} to {day.format(end)} {isDragging ? ' (moving)' : ''} </span> ) : null} {after ? <span>After {after}</span> : null} <span className="slots-card__actions"> <button type="button" aria-pressed={watching} onClick={() => { setWatching(!watching); }} > {watching ? 'Watching' : 'Watch'} </button> <button type="button" onClick={() => { api.hide(); }} > Close </button> </span> </div> );}FullTooltip.slotKey = 'full';
/** Hooks: the context only. */export function CompactTooltip(context: TaskTooltipContext<Meta>) { const owner = useOwner(context.task.task.data); return ( <span> {context.task.name} ({owner?.initials ?? '?'}) </span> );}CompactTooltip.slotKey = 'compact';
/** A tooltip that throws: the chart stays, the tooltip is hidden, and `onError` hears of it. */export function BrokenTooltip(context: TaskTooltipContext<Meta>): never { throw new Error(`The tooltip of ${context.task.name} failed on purpose.`);}BrokenTooltip.slotKey = 'throws';
// ---- The link menu and the bar label: components, called with the context ----//// The other way to write a slot: the function only returns an element, and the logic is in a// component, which may use hooks as any component does.
/** The menu a click on an arrow opens: who hands over to whom, and the remove button. */export function HandoffMenu({ context }: { context: LinkMenuContext<Meta> }) { const { source, target, canRemove, api } = context; const from = useOwner(source?.task.data); const to = useOwner(target?.task.data);
return ( <div className="slots-card"> <strong> {source?.name} to {target?.name} </strong> <span> {from?.name} hands over to {to?.name} </span> <span className="slots-card__actions"> <button type="button" disabled={!canRemove} onClick={() => { context.remove(); }} > Remove dependency </button> <button type="button" onClick={() => { api.hide(); }} > Close </button> </span> </div> );}
/** The label of a bar: the owner's initials, then the name. */export function BarLabel({ context }: { context: BarLabelContext<Meta> }) { const owner = useOwner(context.task.data); return ( <span className="slots-label"> <span className="slots-label__owner">{owner?.initials ?? '?'}</span> {context.name} </span> );}/* * The look of what the slots draw: a card for the tooltip and the menu, and a chip with the * owner's initials in the label of a bar. The chart gives a popover its box and its shadow; this * is only what is inside. */.slots-card { display: grid; gap: 0.25rem; min-width: 11rem; font-size: 0.8125rem;}
.slots-card__actions { display: flex; gap: 0.5rem; margin-top: 0.25rem;}
/* The label is drawn on the bar, which has its own colour: the chip is a lighter patch of it. */.slots-label { display: inline-flex; align-items: center; gap: 0.375rem;}
.slots-label__owner { padding: 0 0.375rem; border-radius: 999px; background: rgb(255 255 255 / 0.3); font-size: 0.6875rem; font-weight: 600;}Files
data.ts: the tasks, with a typeddata(Meta: an owner id), and the links.directory.ts: the application’s own context (two teams of three people) that the slots read.options.ts: the view mode, the first scroll, when the tooltip opens, where the label goes.slots.tsx: the functions and components the slots draw.styles.css: the card inside a tooltip or a menu, and the chip on a bar.react.tsx: the provider,<Gantt>with the three slots, and the controls.
Options
| Option | Value | What it does |
|---|---|---|
renderTooltip |
FullTooltip, CompactTooltip, BrokenTooltip |
The tooltip as React; replaces tooltip.render |
renderLinkMenu |
a function | The menu of an arrow as React; replaces linkMenu.render |
renderBarLabel |
a function | The label of a bar as React; replaces display.renderBarLabel |
tooltip.trigger |
'click' |
A click opens the tooltip and keeps it open, so that its buttons can be pressed |
display.barLabel |
'inside' |
Where a React label goes; the chart cannot measure it, so 'auto' would always say inside |
onError |
a function | Hears what a slot function throws, named after its prop |
onLinkDelete |
a function | Hears of a link removed from the menu with remove() |
ref |
useRef<GanttHandle<Meta>> |
Puts the links back (setLinks) after the menu removed some |
defaultViewMode |
'week' |
The scale of the timeline |
scroll.initial |
'first-task' |
Opens on the plan |
defaultTasks, defaultLinks |
the tasks and links | Where the chart starts with its data |
What to try
- Click a bar. The tooltip is a React component. The owner’s name and role come from the
context, the predecessors from the tooltip’s context (
predecessors, read withapi.getTask), Watch is auseStateof its own, and Close isapi.hide(). - Press Watch, then drag the bar. While a bar is dragged the tooltip shows where it would
land (
preview, marked moving), and it still says Watching: the chart makes the content again for each preview and keeps its place in the page, so the state inside it lives on. - Change the team. The initials on the bars and the names in the open tooltip change at once. Only the value of the provider changed, and the chart was not touched.
- Change the tooltip. compact is another function, with other hooks. Each function says it
is a component of its own with a
slotKey, so the slot starts the new one anew (Watching is forgotten) and React has nothing to complain about. - Break the tooltip. With throws, click a bar. The function throws while React renders its
content. No tooltip opens, the chart and the page stay as they are, and
onErrorsays which prop it was. (React also logs the error to the console, as for any error caught around a component.) - Click an arrow. The menu says who hands over to whom. Remove dependency calls
context.remove(): the chart removes the link andonLinkDeletesays so. Restore links puts them back.
Notes
- A slot function is the body of a component. It is called while React renders, once for each tooltip, menu or label on show, and again when its context changes, so it may call hooks, and they keep their state while the chart shows the same task or link. An inline arrow function that is new on every render counts as the same function: the chart is not told, and the latest one draws.
- The same hooks in the same order. A function that is swapped for another with different
hooks is another component. Give each its own
slotKey(FullTooltip.slotKey = 'full') and the slot starts the new one anew. Without aslotKey, React cannot tell, and throws when the number of hooks differs; the slot recovers once, but React logs the error in development. Keeping the logic in a component and returning an element (<HandoffMenu context={context} />, as the menu and the label do here) avoids it altogether. - Errors. A function that throws is caught around its own content: the tooltip or menu is
hidden, a label draws nothing, and the error goes to
onErroras{ error, source: 'listener', name: 'renderTooltip' }, or toreportErrorwithout a handler. The slot is drawn again when its context or its function changes. Errors in event handlers and effects are yours, as everywhere in React. - Labels. One portal for each visible bar. The chart does not measure what React draws, so it
cannot tell if a label fits:
'auto'always puts it inside the bar, clipped. Choose'inside'or'outside'. A label takes no pointer events. - Popovers take pointer events only when they stay open: the menu, and a tooltip opened by a click. A hover tooltip is for reading and has no buttons.
- Empty is nothing.
null,false,''and an element that wraps nothing show no tooltip or menu. - With
useGantt, renderslotsnext to the container instead: Hooks. Nothing is drawn on a server; the slots come with the chart after hydration. - The vanilla functions still work (
tooltip.render,linkMenu.render,display.renderBarLabel); when both are given, the slot is used and development warns once. See Custom tooltip for the vanilla side and a simple React tooltip.