@ganttlane/react
v0.5.0
Published
React components and hooks for interactive gantt charts built on @ganttlane/core.
Maintainers
Readme
@ganttlane/react
React bindings for @ganttlane/core: a <Gantt> component and the useGantt /
useGanttState hooks. It is a wrapper over the vanilla renderer of @ganttlane/dom:
the chart in the page is the one createGantt draws, with the same DOM, classes, stylesheet,
pointer input and scrolling, and this package adds the React side: props for options, controlled and
uncontrolled state, handlers that are never stale, ReactNode slots for the tooltip, the link menu
and the bar label, a ref handle, StrictMode and server rendering.
Documentation: the documentation site has the guides (including a React guide), live examples next to their code (each in vanilla and in React) and the generated API reference of every export. This README is the reference of the package.
Status:
0.x: the API may still change between minor versions until1.0.
Coming from frappe-gantt, or from a modified fork of it? See the migration guide.
Contents
- Install
- Quick start
- Concepts
- Props
- Controlled and uncontrolled
- Events
- Methods
patchTaskson a server:@ganttlane/react/core- Hooks
- Compound components:
GanttRoot,GanttChartanduseGanttBar TaskList- Slots: tooltip, link menu and bar label as
ReactNode - The
refhandle - StrictMode, server rendering and hidden trees
- Theme and styling
- Accessibility
- Browser and React support
- Size
- Limits and roadmap
- Examples
Install
# pick one
pnpm add @ganttlane/react react react-dom
npm install @ganttlane/react react react-dom
yarn add @ganttlane/react react react-dom
bun add @ganttlane/react react react-dom- React 18.3 or 19 (
reactandreact-domare peer dependencies,^18.3.0 || ^19.0.0). ESM only, with TypeScript declarations; TypeScript must resolve packageexports(moduleResolutionofbundler,node16ornodenext). @ganttlane/reactdepends on@ganttlane/domand@ganttlane/core, so this one install is enough. Strict pnpm lets you import only what your ownpackage.jsonlists, so this package re-exports what a React application meets:patchTasks, and every type that the props and therefhandle name, however deep (Task,Link,TaskRecord,ChangeSet,Change,Conflict,SessionChanges,Issue,IssuesPayload,EditRefusedPayload,UpdateTaskResult,LinkType,SnapRule,ViewMode,TaskTooltipContext,LinkMenuContext,BarLabelContext,GanttStringsInput, …), types only. For the building blocks (createGanttCoreand the like) add@ganttlane/core, on the same version.- The stylesheet is a separate import, once per page:
@ganttlane/react/styles.css. It is a copy of@ganttlane/dom/styles.cssthat the build of this package makes, byte for byte, so that an app that installed only@ganttlane/reactcan import it (under strict pnpm the app cannot reach@ganttlane/dom, which is this package's dependency, not its own). Import one of the two, not both; the styles are the same and the classes, data attributes and--gantt-*custom properties are those of the vanilla chart. The package marks CSS as a side effect ("sideEffects": ["**/*.css"]), so that a bundler which drops unused imports of side-effect-free packages keeps the import. TypeScript 6 checks side-effect imports (noUncheckedSideEffectImportsis on by default) and would report TS2882 for a file it has no declarations for: the stylesheet subpath has an (empty) declaration file of its own, soimport '@ganttlane/react/styles.css'type-checks as it is.
Quick start
import '@ganttlane/react/styles.css';
import { Gantt } from '@ganttlane/react';
export function Plan() {
return (
<Gantt
style={{ height: 480 }}
defaultTasks={[
{ 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' },
]}
defaultLinks={[{ source: 'design', target: 'build' }]}
defaultViewMode="week"
scroll={{ initial: 'first-task' }}
theme="auto"
onTaskClick={(record) => {
console.log(record.id);
}}
/>
);
}<Gantt>renders onediv(give itclassNameandstyle) and draws the chart into it. The container needs a height, as withcreateGantt: the chart fills it and scrolls inside it. Or give the chart its own withlayout.height.id,aria-*anddata-*props go to the samedivas they are (<Gantt id="plan" aria-label="Release plan" data-testid="plan" />, typed as React types them); they are no options of the chart.defaultTasksanddefaultLinksare where an uncontrolled chart starts: it owns the data and the handlers tell you what happened. To keep the tasks in your own state, passtasksinstead; see Controlled and uncontrolled.- A chart opens at today (
scroll.initial: 'today'), which for a plan in the past or the future is the nearest end of its range; use'first-task'or'start'for fixed dates. - The caller's props, tasks and links are never mutated.
Concepts
The data model, the scheduling rules and the edit flow are the core's; the short version is in
@ganttlane/dom and the details in
@ganttlane/core. What this package adds:
- Options are props. Every option of
createGanttis a prop, with the same name and type (calendar,scheduling,layout,display,theme,locale,strings,tooltip,timeline,editing,history, …), and so is everyon<Event>handler. A prop that changes later changes the chart without recreating it. - Five groups can be controlled by your state, with the React Aria naming:
tasks,links,viewMode,expandedKeys,selectedKeys, each with adefault…twin and a change handler. - Handlers are never stale, so they may close over state and be new on every render.
- Tooltip, link menu and bar label are
ReactNodes, drawn through portals in your tree. GanttRootandGanttChartshare one chart with the components around it, anduseGanttBarfollows one task and its bar.- A
refhandle gives the methods of the chart;useGanttis the component without thedivanduseGanttStatereads the chart's state in your render. - StrictMode, server rendering and hidden
<Activity>trees are supported.
Props
| Prop | Type | Meaning |
| ---------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------- |
| tasks / defaultTasks | Task<TData>[] | The tasks: controlled / the start of an uncontrolled chart |
| links / defaultLinks | Link[] | The links, the same way |
| viewMode / defaultViewMode | string (a view mode id) | The view mode |
| expandedKeys / defaultExpandedKeys | 'all' | TaskId[] | The expanded parents (in place of hierarchy.expandedKeys) |
| selectedKeys / defaultSelectedKeys | TaskId[] | The selected tasks |
| onLinksChange | (links) => void | The links after one was drawn or removed (your links with the change applied) |
| renderTooltip | (context) => ReactNode | The tooltip of a task, as React: see Slots |
| renderLinkMenu | (context) => ReactNode | The menu a click on an arrow opens |
| renderBarLabel | (context) => ReactNode | The label of a bar |
| hierarchy | { childAnchor, moveChildren } | The hierarchy group without expandedKeys, which is a prop of its own |
| className, style, id, aria-*, data-* | as React types them | Go to the container div |
| renderer | { idPrefix, layers, frames } | Read when the chart is created and never again |
Every other option of createGantt is a prop with the same name, type and
default: calendar, scheduling, dates, layout (with height), timeline, scroll, locale,
strings, theme, display, highlights, tooltip, linkMenu, highlightTooltip, editing,
history, interaction, viewModes, now, direction, toolbar, and the on<Event> handlers.
There is no dataMode prop: tasks makes the chart controlled and defaultTasks leaves it
uncontrolled. The full lists, types and defaults of the options are in
@ganttlane/dom (the renderer's) and
@ganttlane/core (the data and the rules).
TData, the type of task.data, flows through tasks / defaultTasks into every handler and
render function.
How props reach the chart
After every commit the component works out what changed since the last one and gives
setOptions exactly that, so a re-render costs nothing when nothing changed, and a prop that
changes costs what the same setOptions costs on the vanilla chart.
- Compared by data, not only by identity. An option written inline is the same option while
its data is:
calendar={{ weekends: [0, 6] }}ortooltip={{ delay: 300 }}on every render sends nothing. Arrays, plain objects andDates are compared by their contents; functions are compared by identity, so an option object that holds a function (tooltip.render,display.renderBarLabel,calendar.isWorkingDay,linkMenu.render,now) changes on every render unless the function is memoized (useCallback, or defined outside the component). - Groups go field by field, the way
setOptionsmerges them (calendar,dates,scheduling,hierarchy,layout,timeline,scroll,interaction,display,strings).scheduling={{ snap: '1h' }}after{ snap: '1h', propagation: 'none' }sends{ scheduling: { propagation: undefined } }:setOptionsalone would keep the old field, props do not. A group you drop altogether is reset the same way, field by field (not as oneundefined, which would also reset what is the chart's own, such as the expanded parents an uncontrolled chart holds inhierarchy). The merge is one level deep: a field that is an object or a list itself (scheduling.skipNonWorkingDays,timeline.range,scroll.autoScroll,interaction.handles, …) is sent whole when it changes.tooltip,linkMenu,highlightTooltip,editing,history,viewModesand every list are taken whole. - A prop that goes away goes back to its default. Remove
localeand the chart is'en'again. tasksandlinksare compared by identity. The same array again is nothing; a new array is given to the core, which diffs it by id. Controlledtaskshave a consequence: a new array is the owner's answer and ends the draft of a change you have not applied yet (the bar goes back to where your tasks have it), also when its content is the same. So memoize the tasks you derive (useMemo, or keep them in state), or any re-render of the parent (thesavingstate you set inonTasksChange!) would end the draft of the change that is being saved; and to discard a draft after a failed save, pass a new array (setTasks((tasks) => [...tasks])). In development a new array with the same content while a draft is pending is reported once. Links that equal the ones the chart has are not sent: giving them to an uncontrolled chart would end its undo history.- Handlers are always current. The core is given one stable function per
on<Event>prop that calls the latest one, so a handler that is new on every render, or closes over state, is never stale and never asetOptions. Giving a handler for the first time, or removing it, is the only change. A before-hook (onBeforeTaskChange,onBeforeLinkCreate) is a handler too, and its answer is the core's. - The first events arrive after the first commit. What the chart emits while it is created and
mounted (
onIssuesfor the first options,onRangeChangewith reason'initial') is held back while the component renders and delivered in order once it is committed. Nothing in the application is called during a render. With<Gantt ref>they come after therefis set, soref.currentis the handle in these handlers. - A handler that throws never breaks the commit. What a handler throws while the chart is
brought to the props (the first events, and the events a prop change causes) goes where the
core sends the error of a listener: to
onError(and the listeners ofinstance.on('error')) as{ error, source: 'listener', name },namebeing the event ('rangeChange'), one call for each error, and the other handlers still run. With nobody listening toerrorit is rethrown outside the commit, to the host's unhandled-rejection channel, as the core does (one error as it is, several as anAggregateError). The same holds where the core drives the chart: a click. - The issues of the options are one list, as on the vanilla chart:
onIssues, andinstance.getIssues().
Controlled and uncontrolled
Five groups follow the React Aria convention (value / defaultValue / onValueChange):
| Value prop | Start value | Handler (arguments as in Events) |
| -------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------ |
| tasks | defaultTasks | onTasksChange(changeSet) |
| links | defaultLinks | onLinksChange(links) (also onLinkCreate, onLinkDelete) |
| viewMode | defaultViewMode | onViewModeChange(viewMode), the ViewMode (read its id) |
| expandedKeys | defaultExpandedKeys | onExpandedChange(expandedKeys) |
| selectedKeys | defaultSelectedKeys | onSelectionChange(selectedKeys, selectedLink) |
A group is controlled when its value prop is not undefined the first time the chart
renders, uncontrolled otherwise, and keeps that mode for the life of the component. A switch
(viewMode={undefined} on a later render, or a viewMode that appears) is reported once in
development with console.warn and ignored: a controlled group holds the last value it had, an
uncontrolled one ignores the new prop.
Data that loads is the exception. tasks={data?.tasks} is undefined until the data is
there, and a chart that ignored the tasks that then arrive would stay empty for good. So tasks
(and links) going from undefined to a value makes the group controlled from then on: the
value is applied (the chart becomes dataMode: 'controlled'), and development warns once. Going
from a value back to undefined is the switch above: the chart keeps the last tasks. To avoid
the warning, pass tasks={data?.tasks ?? []} from the first render.
Uncontrolled. The chart keeps the value,
default…is where it starts, and the handler only tells you what happened. A differentdefault…later is not a change.Controlled. The prop is the truth. The chart follows it whenever it changes (and tells no handler about it: you caused it). When the chart changes the value itself (the user selects a link, a method such as
instance.collapse('p')runs) the handler is called, the component renders again, and if the prop did not change the chart is set back to it. So the value changes only when your state does.tasksis the exception to "set back". Controlled tasks run the core withdataMode: 'controlled': the core never changes them, every committed change is aChangeSetgiven toonTasksChange, and it is shown as a draft until you pass the new tasks back, so you may take your time (a save request) without the bar jumping back. Write the change set into your own tasks withpatchTasks:import { useState } from 'react'; import { type ChangeSet, patchTasks, type Task, useGantt } from '@ganttlane/react'; export function Plan({ initialTasks }: { initialTasks: Task[] }) { const [tasks, setTasks] = useState(initialTasks); // Give the handler a return type: it reads `gantt`, which it is passed to. const apply = (changes: ChangeSet): void => { // `patchTasks` writes dates in the form the task has (Date, ISO string, epoch ms). const { model } = gantt.core.getSnapshot().state.derived; setTasks((current) => patchTasks(current, changes, model)); }; const gantt = useGantt({ tasks, onTasksChange: apply }); return <div ref={gantt.setContainer} style={{ height: 480 }} />; }Passing the same array again is a no-op. A new array, even an equal one, is your answer and ends a pending draft: memoize what you derive, and pass a new array to discard a draft after a failed save (see above). A controlled chart with editing on (the default;
editing: falseturns off editing from the UI) and noonTasksChangeis reported once in development: the change would never be given to you.Links.
linksis controlled like the others. With controlledtasks, passlinkstoo: a link the user draws or removes is not applied until you pass the new list back, andonLinksChangegives you that list (yourlinkswith the change). A link that comes fromtask.dependenciesis in no list of links, so deleting one leaveslinksas it was and onlyonLinkDeletereports it.defaultLinkswith controlledtasksstarts the links but leaves the application to keep them.Selection.
selectedKeysis the selected tasks; the selected link (a click on an arrow) is the chart's own and survives it. Keys that name tasks the chart does not have yet are selected when the tasks arrive (tasksloaded later), and adefaultSelectedKeysthat names tasks that load later is finished the same way, until the user (or a method) selects something else. A key that names no task at all is ignored.expandedKeysreplaceshierarchy.expandedKeys(ahierarchyprop holds the other hierarchy options).'all'or a list of ids.
Edit mode: Save and Cancel
An edit session (instance.edit.begin(), commit(), rollback(), see Methods)
works with controlled tasks and links as it does on the vanilla chart, with one more step
that is yours: the chart does not change controlled data, so what a Cancel puts back arrives as
onEditRollback (the inverse of the session) and you write it into your state, the way you write
onTasksChange.
import { useState } from 'react';
import {
type ChangeSet,
type Link,
patchTasks,
type SessionChanges,
type Task,
useGantt,
} from '@ganttlane/react';
const INITIAL_TASKS: Task[] = [
{ id: 'design', name: 'Design', start: '2026-03-02', end: '2026-03-06' },
{ id: 'build', name: 'Build', start: '2026-03-09', end: '2026-03-20' },
];
const INITIAL_LINKS: readonly Link[] = [{ source: 'design', target: 'build' }];
const linkId = (link: Link): string =>
link.id ?? `${String(link.source)}:${link.type ?? 'FS'}:${String(link.target)}`;
// The links after a cancelled session: `removed` are to drop, `added` to restore. They are the
// chart's link models: `model.id` is the link's id and `model.link` is your own `Link`.
function restoreLinks(links: readonly Link[], { added, removed }: SessionChanges['links']): Link[] {
const dropped = new Set(removed.map((model) => model.id));
return [
...links.filter((link) => !dropped.has(linkId(link))),
...added.flatMap((model) => model.link ?? []),
];
}
export function Plan() {
const [tasks, setTasks] = useState(INITIAL_TASKS);
const [links, setLinks] = useState(INITIAL_LINKS);
// A committed change (`onTasksChange`) and the inverse of a cancelled session
// (`onEditRollback`) are both written with `patchTasks`.
const apply = (changes: ChangeSet): void => {
const { model } = gantt.core.getSnapshot().state.derived;
setTasks((current) => patchTasks(current, changes, model));
};
const gantt = useGantt({
tasks,
links,
editing: true,
onTasksChange: apply,
onLinksChange: setLinks,
onEditRollback: (changes) => {
apply(changes);
setLinks((current) => restoreLinks(current, changes.links));
},
});
return (
<>
<button type="button" onClick={() => gantt.edit.begin()}>
Edit
</button>
<button type="button" onClick={() => gantt.edit.commit()}>
Save
</button>
<button type="button" onClick={() => gantt.edit.rollback()}>
Cancel
</button>
<div ref={gantt.setContainer} style={{ height: 480 }} />
{gantt.slots}
</>
);
}commit() returns { status: 'committed', changes } with the net changes of the session
(SessionChanges; 'no-session' or 'busy' mean nothing was committed), and the data is already
yours (every gesture was applied as it happened);
edit.getChanges(), edit.isOpen and edit.isDirty tell where the session stands, and the session
is part of the snapshot (state.session), so
useGanttState(gantt, (snapshot) => snapshot.state.session !== undefined) re-renders the buttons.
The ref handle is a source of snapshots like the instance of useGantt. The full recipe, with a
Save that sends the plan to a server and puts the last saved plan back when that fails, is in
examples/edit-session. A restored link is the link of
the model the session kept (model.link, your own Link; none for a link that came from
task.dependencies). The playground page /react/
(apps/playground/src/react-app.tsx) is this recipe in
full: view and edit mode, Save and Cancel with the changes panel, theme and locale as props, the
ReactNode slots and an event log.
An edit session also ends, as a Cancel, when a hidden <Activity> takes the chart away; see
"StrictMode, server rendering and hidden trees" below.
Events
Every event of the chart is an on<Event> prop, with the arguments listed under
Events in the @ganttlane/dom README: onTasksChange,
onTaskDatesChange, onTaskProgressChange, onLinkCreate, onLinkDelete, onLinkClick,
onTaskClick, onTaskDoubleClick, onTaskHoverChange, onSelectionChange, onViewModeChange,
onRangeChange, onExpandedChange, onScroll, onEditRollback, onDirtyChange,
onHistoryChange, onEditRefused, onIssues and onError. This package adds onLinksChange
(the links after one was drawn or removed), and the two before-hooks, onBeforeTaskChange and
onBeforeLinkCreate, are props too.
onViewModeChangereceives theViewModeobject (read itsid);onSelectionChangereceives(selectedKeys, selectedLink);onExpandedChangethe expanded keys.- A handler may close over state and be a new function on every render: the chart is given one stable function per event that calls the latest one (see How props reach the chart).
- What a handler throws goes to
onError({ error, source: 'listener', name }), never into React. With noonError, a call of yours on the handle or the instance (select,updateTask, …) rethrows what its handlers threw, as on the vanilla chart (a sync throw, or a rejected promise forupdateTask,addLinkandremoveLink), and the chart's own input (a click, a scroll) goes to the host's unhandled-rejection channel; see Events of@ganttlane/dom. - Listeners that are not tied to a render (a log, an analytics call) can be added to the instance
or the handle with
on(name, handler), which returns a function that removes them.
Methods
The methods of the chart are the ones of the vanilla Gantt, without
destroy, which React does for you, on two objects: the ref handle of <Gantt>
(useRef<GanttHandle>(null)) and the instance useGantt returns. They are data
(setTasks, setLinks, updateTask, addLink, removeLink), setOptions (an escape hatch: a prop
that changes later wins over it), the view and scrolling (setViewMode, zoomTo, scrollToDate,
scrollToToday, scrollToTask, scrollTo), rows (expand, collapse, toggle, expandAll,
collapseAll, setHoveredRow, focusTask, focusBar), selection (select, selectLink, clearSelection),
queries (getTask, getLink, getVisibleRange, getRowMetrics, getBarRect, getRows,
getRowWindow, getLayoutMetrics, getAppearance, getIssues), hideTooltip, the edit session (edit.begin,
commit, rollback, getChanges, isOpen, isDirty), history (undo, redo, canUndo,
canRedo; they need history: true), on and core, plus getSnapshot() and subscribe(). See
The ref handle and Hooks for how to hold them.
patchTasks on a server: @ganttlane/react/core
The main entry starts with 'use client', since everything in it is a component or a hook. A
server component (or a server action, a route handler) that imports patchTasks from it does not
get the function but a reference to a client module, which it cannot call. @ganttlane/react/core
has no directive and nothing of React in it: it exports patchTasks and the types of the data you
give, the events you get and the results of the handle's methods (Task, ChangeSet,
SessionChanges, TaskRecord, Link, UpdateTaskResult, …), the same ones the main entry exports.
// In a module that server code and client code share, or in a server action.
import { patchTasks, type ChangeSet, type Task } from '@ganttlane/react/core';patchTasks(tasks, changes, model) takes the model of a core (core.getSnapshot().state.derived.model),
so on a server it goes with a core made there (createGanttCore of @ganttlane/core); the types
need nothing. In client code the main entry exports the same function.
Hooks
useGantt(options)
useGantt is the component without the div: <Gantt {...options} /> is
useGantt(options), <div ref={gantt.setContainer} /> and {gantt.slots}. It returns an instance, the same
object for the life of the component, with the methods of the vanilla
Gantt (setTasks, updateTask, setViewMode, scrollToTask,
select, edit.begin / commit / rollback, undo, on, queries, …) without destroy
(React ends the chart), and:
| Member | What it is |
| ----------------------- | ----------------------------------------------------------------------------------- |
| setContainer(element) | The ref of the element the chart fills: drawn while attached, removed when detached |
| slots | The portals of the ReactNode slots, to render next to the container (see Slots) |
| core | The headless core (GanttCore), the same object for the life of the component |
| getSnapshot() | The core's current snapshot |
| subscribe(listener) | Calls listener after every change of the snapshot |
The core is made while the component renders, so instance.getTask(…) and getSnapshot() already
answer in the first render and on a server. The renderer is drawn when a container is attached.
The first events (those of the creation) reach the handlers in the last layout effect of
useGantt, so a layout effect you declare after the hook runs after them; <Gantt> sets its ref
before them. instance.setOptions(patch) is an escape hatch: a prop that changes later wins over it.
instance.core is one object whichever core is behind it, like the instance: it stands for
the core the chart has now, which is a new one when a hidden <Activity> shows the chart again,
and its getSnapshot, subscribe and on follow the chart. So it can be kept, and given to
useGanttState(instance.core, …), which keeps reading the chart after that.
useGanttState(instance, selector, isEqual?)
Below a GanttRoot the instance can be
left out, useGanttState(selector, isEqual?): the hook takes the root's chart.
Reads a slice of the snapshot through useSyncExternalStore and renders again only when the
slice changes:
import { useGantt, useGanttState } from '@ganttlane/react';
export function Status() {
const gantt = useGantt({ defaultTasks: tasks, defaultViewMode: 'week' });
const mode = useGanttState(gantt, (snapshot) => snapshot.viewMode.id);
const selected = useGanttState(gantt, (snapshot) => snapshot.selection.tasks);
return (
<>
<div ref={gantt.setContainer} style={{ height: 480 }} />
{gantt.slots}
<p>
{mode}, {selected.length} selected
</p>
</>
);
}The selector may be written inline. The default isEqual is Object.is: pick primitives or parts
of the snapshot, which keep their identity until they change. A selector that builds a new object
or array each time needs an isEqual (a shallow one), or the component renders on every change
of the chart. useGanttState also takes a core made with createGanttCore. It has a server
snapshot, so it works in a server render and hydrates.
Slots: tooltip, link menu and bar label as ReactNode
Three places of the chart have content of their own: the tooltip of a task, the menu a click on a
link opens, and a bar's label. Each takes a function that returns a ReactNode, which React draws
through a portal into the node the vanilla renderer shows there:
import { Gantt } from '@ganttlane/react';
// `TaskCard` and `LinkMenu` are your own components.
export function Plan() {
return (
<Gantt
style={{ height: 480 }}
defaultTasks={tasks}
tooltip={{ trigger: 'click' }}
display={{ barLabel: 'outside' }}
renderTooltip={({ task, preview, isDragging }) => (
<TaskCard task={task} dates={preview} dragging={isDragging} />
)}
renderLinkMenu={({ source, target, canRemove, remove }) => (
<LinkMenu from={source?.name} to={target?.name} onRemove={canRemove ? remove : undefined} />
)}
renderBarLabel={({ name, progress }) => (
<b>
{name} {Math.round(progress)}%
</b>
)}
/>
);
}| Prop | Called with | Same as in createGantt |
| ---------------- | -------------------------------------- | ------------------------ |
| renderTooltip | TaskTooltipContext (api.hide()) | tooltip.render |
| renderLinkMenu | LinkMenuContext (remove()) | linkMenu.render |
| renderBarLabel | BarLabelContext (id, name, progress) | display.renderBarLabel |
Your tree, your context. The portals are rendered next to the container, so what is in them sees the providers above
<Gantt>(theme, i18n, a store), reads your state and can use hooks: each tooltip, menu or label is the body of a component of its own (see "Hooks in the function" below), drawn once for each the chart shows. For labels that is one portal per visible bar, which is the cost to know: measured on the playground (/react/?slots=1), 107 bars in a 5000 px tall chart are 107 portals that mount in 5 to 20 ms (Firefox to WebKit) and leave the frame time of a scroll unchanged at normal CPU speed in Chromium, Firefox and WebKit. Keep the logic in a component and return an element:renderTooltip={({ task }) => <TaskCard task={task} />}.Always the latest function, and no churn. The function is read when the portals draw, like a handler: a new one on every render (an inline arrow function) is no option change and costs the chart nothing. A state change in the parent draws the portals again, without making the popover or the bar again; the state inside the content (a counter, an input) survives the chart making its tooltip again for the same task (a drag preview, a new record), because the tooltip, the menu or the label of one task or link keeps the same host while the chart shows it.
Nothing leaks. A portal is unmounted when its tooltip or menu closes (the hover leaves, a press outside, Escape,
api.hide()), when the tooltip is replaced by another task's, when a bar leaves the window, when labels are hidden (display.barLabel: 'none') or replaced, and when the chart unmounts, under StrictMode too. The renderer tells React through a small contract (DisposableNodein@ganttlane/dom).Empty is nothing.
null,undefined,false,'', whitespace and elements that wrap nothing (<div />,<p> </p>) show no tooltip or menu, asnulldoes fortooltip.render; an element that draws without text (animg, ansvg, aninput, abutton) is something. A box that CSS alone draws (a colour swatch) counts as nothing: give it ansvgor some text. A tooltip whose content becomes empty while it is open closes. It is the rule of{ html }in the vanilla popover (showsNothingin@ganttlane/dom/dom-utils).Pointer events and focus. A menu, and a tooltip that stays open (
tooltip.trigger: 'click'), take pointer events, so buttons and inputs in them work; a hover tooltip is for reading and takes none (it closes when the pointer leaves the bar). The menu moves the focus to its first button unless your content has taken it (autoFocus), and Escape and a press outside close it. The focus and what is typed in an<input>in a tooltip, menu or label survive the chart making it again (a controlledtasksthat follows every keystroke is the case): the host is kept in the page, never taken out and put back. A bar's label takes no pointer events.In the document. A tooltip or menu is drawn when its host is in the page and about to be measured, so
autoFocus, a layout effect that reads a size and arefthat measures see attached nodes. The popover is placed by what you drew.Portals inside the content. A select, a menu or a date picker in your content that renders into
document.body(its owncreatePortal, however deep) is part of the content: a press on it is not a press outside, and a scroll of its list does not close the popover. This is read off React's fiber tree (the nearest rendered ancestor of the pressed element leads, throughreturn, to the portal of the host), because the chart asks in the capture phase of the document, before React has seen the event; it reads a private pointer of React 18 and 19, and if a future React changes it a press in such a portal closes the popover, as any press outside.Labels and
'auto'placement. The chart does not measure what React draws (it never reads layout while it renders), and the node of a label is kept between draws, holding what was drawn last: the width of a React label is not known, and not guessed from that text.'auto'places a React label inside the bar (clipped to it), the same on every draw whatever the label said before; it goes outside only in a bar narrower than the label's padding. Choose'inside'or'outside'for a label that needs the one or the other (outside, the label does not clip). A tooltip is sized when it opens and is not measured again, so content whose size changes while it is open is not placed again.Hooks in the function. The function is called as the body of the slot's component, so it may use hooks, and they stay: the same hooks in the same order for as long as the function is the one the slot shows. A function that is new on every render (an inline arrow) is the same function as far as this goes, and the state in it survives. A different function with other hooks (
renderTooltip={compact ? renderCompact : renderFull}) is another component: give each its ownslotKeyand the slot starts it anew with no error:import type { TaskTooltipContext } from '@ganttlane/react'; // `Compact` is your own component. const renderCompact = Object.assign((c: TaskTooltipContext) => <Compact {...c} />, { slotKey: 'compact', });Without a
slotKeyReact cannot tell the swap, and throws when the hooks differ in number; the slot catches that, starts the new function anew once and shows it (React still logs the error in development), and hooks of the same number and other kinds go unnoticed, so key the functions that differ. Keeping the logic in a component (<TaskCard task={task} />) avoids all of it.A function that throws. What the function throws while React renders its content does not unmount your app: it is caught around the slot, the tooltip or menu is hidden (
api.hide()), a label draws nothing, and the error is reported toonError, as the errors of the vanilla functions are (to the page, withreportError, when there is no 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.Not on a server. There is no renderer on a server, so there are no portals: the server markup is the container
divonly, and the slots are drawn after hydration.With
useGantt, renderslotsnext to the container: a stable element that draws the portals and updates itself. A slot given and not drawn is reported once in development. Destructure it together withsetContainer, which keeps the lint rules of React (react-hooks/refs) quiet:import { useGantt } from '@ganttlane/react'; export function Plan() { const { setContainer, slots } = useGantt({ defaultTasks: tasks, renderTooltip: (context) => <TaskCard task={context.task} />, }); return ( <> <div ref={setContainer} style={{ height: 480 }} /> {slots} </> ); }The vanilla functions still work (
tooltip.render,linkMenu.render,display.renderBarLabel, returning aNodeor{ node, dispose }); when both are given the slot is used and one warning says so in development.
The ref handle
<Gantt ref> is the chart's handle: the object of useGantt without what is for React
(setContainer, slots), so the methods of Methods, core, getSnapshot() and
subscribe().
import { useRef } from 'react';
import { Gantt, type GanttHandle } from '@ganttlane/react';
export function Plan() {
const chart = useRef<GanttHandle>(null);
return (
<>
<button type="button" onClick={() => chart.current?.scrollToTask('build')}>
Go to Build
</button>
<Gantt ref={chart} defaultTasks={tasks} style={{ height: 480 }} />
</>
);
}The same object for the life of the component, set when the chart is committed (before the layout effects of the parent, which may use it at once, and before the handlers hear of the first events) and
nullafter the component unmounts, and while a hidden<Activity>has the chart. It survives StrictMode (the one object, one chart). It is an object of its own, frozen, withoutsetContainerandslots: they are not there at run time either.Typed with
TData.GanttHandle<TData>is the handle of a chart ofTask<TData>:useRef<GanttHandle<Row>>(null)goes withdefaultTasksofTask<Row>[](or<Gantt<Row> …>), and anotherTDatais a type error.forwardRef, which works in React 18.3 and 19. AforwardRefcomponent cannot be generic, soGanttisforwardRefcast to a generic function component. Do the same to wrap<Gantt>in a component of your own that keeps therefand theTDatatype:import { type ForwardedRef, forwardRef, type ReactElement, type RefAttributes } from 'react'; import { Gantt, type GanttHandle, type GanttProps } from '@ganttlane/react'; function PlanImpl(props: GanttProps, ref: ForwardedRef<GanttHandle>): ReactElement { return <Gantt {...props} ref={ref} className="plan" />; } export const Plan = forwardRef(PlanImpl) as <TData = unknown>( props: GanttProps<TData> & RefAttributes<GanttHandle<TData>>, ) => ReactElement | null;With React 19 only,
refas a prop would do withoutforwardRef; this package does not need 19 for it.One object. With
useGanttthe handle is the instance the hook returns, andrefis for when you render<Gantt>and want its methods.
Compound components: GanttRoot, GanttChart and useGanttBar
<Gantt> is a chart in one element. For a page with a toolbar, a list or a panel around the chart,
GanttRoot makes the chart and shares it with everything below it, and GanttChart is the element
it is drawn into:
import '@ganttlane/react/styles.css';
import { GanttChart, GanttRoot, TaskList } from '@ganttlane/react';
export function Plan() {
return (
<GanttRoot defaultTasks={tasks} defaultLinks={links} defaultViewMode="week" history>
<Toolbar />
<div style={{ display: 'flex', height: 480 }}>
<TaskList />
<div style={{ position: 'relative', flex: 1, minWidth: 0 }}>
<GanttChart style={{ height: '100%' }} />
{/* Placed in this element: `box` is measured from the chart's corner. */}
<TaskPanel />
</div>
</div>
</GanttRoot>
);
}<GanttRoot {...options}>takes the props of<Gantt>(the options, the controlled groups, the handlers and the slots) andchildren, and behaves as<Gantt>does: only what changed reaches the chart, handlers are never stale, StrictMode and a server render are fine. It draws no element of its own, and the portals of the slots are inside it, so a slot's content sees its context.<GanttChart>is thedivthe chart is drawn into:id,className,style,aria-*anddata-*go to it as they do for<Gantt>. It needs a height. One per root; a second is reported in development.useGanttContext()is the chart of the root: the instanceuseGanttreturns, the same object for the life of the root.useGanttState(selector, isEqual?)isuseGanttState(gantt, selector, isEqual?)with that chart.<TaskList>takes it when it is given nogantt. Outside a root they throw, unless you pass the chart (useGanttBar(id, gantt),useGanttState(gantt, selector)).- A context cannot know the type of
task.data:GanttRootinfers it from its props, and below it you name it,useGanttContext<MyData>(),useGanttBar<MyData>(id),useGanttState<MyData, T>(...). - A
<Gantt>below a root is a chart of its own, which these hooks do not see.
useGanttBar(id, gantt?)
For a panel, a popover or an editor that belongs to one task and goes with its bar. It returns
undefined without an id and for an id the chart does not have, else:
| Field | Meaning |
| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| task | The TaskRecord, with the committed values |
| draft | { start, end, endExclusive, progress }: what the chart shows while the bar is dragged, or a change of controlled data the owner has not applied yet; else undefined |
| isDragging | The bar is being dragged, resized or its progress moved |
| isSelected | The task is selected |
| isFocused | The keyboard focus is on its bar or row |
| box | { left, top, width, height } in px from the top left corner of the GanttChart element, the header included and the scroll taken off; undefined for a task with no bar on the screen (a placeholder, a collapsed row, a chart not measured yet, a server render) |
function TaskPanel() {
const id = useGanttState((snapshot) => snapshot.selection.tasks[0]);
const bar = useGanttBar(id);
if (bar?.box === undefined) return null;
const { task, draft, box } = bar;
return (
<aside style={{ position: 'absolute', left: box.left, top: box.top + box.height }}>
{task.name}: {Math.round(draft?.progress ?? task.progress)}%
</aside>
);
}box changes while the chart scrolls and the bar is dragged, so the component that calls the hook
renders then: keep it in a component of its own. It renders again only when a field changes, not
for other tasks, hovering or a new view of the chart. box is outside the element when the bar is
scrolled out of view: compare it with snapshot.timeline.viewportWidth and snapshot.viewportHeight.
It is measured from the chart, which fills the GanttChart element, so give that element no padding.
With layout.height: 'auto' the page scrolls the chart and the bar does not move in its element, so
box does not change when the page scrolls.
An app that imports only <Gantt> does not ship any of this. See the
example.
TaskList
<TaskList gantt={instance} /> is the list of tasks that a classic gantt tool has on its left: a row
for every row of the chart at the same height and place, indented by depth, with a toggle on the
groups, the columns you choose and a splitter on its end edge. It takes the instance of
useGantt, which exists while your component renders, so the list also renders on a
server. A ref of <Gantt> would be null until the commit. Below a GanttRoot
the gantt prop can be left out: the list takes the root's chart.
import '@ganttlane/react/styles.css';
import { TaskList, useGantt } from '@ganttlane/react';
const columns = [
{ id: 'name', header: 'Task' },
{ id: 'done', header: '%', width: 48, align: 'end', value: (row) => `${row.record.progress}` },
];
function Plan() {
const gantt = useGantt({
defaultTasks: tasks,
layout: { height: 500 },
selection: { onClick: true },
});
const { setContainer, slots } = gantt;
return (
<div style={{ display: 'flex' }}>
<TaskList gantt={gantt} columns={columns} />
<div ref={setContainer} style={{ flex: '1 1 0', minWidth: 0 }} />
{slots}
</div>
);
}| Prop | What it does |
| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| gantt | The chart |
| columns | { id, header, width, align, tree, value, render }[]; render returns a ReactNode. Memoize them if your component renders often |
| width, defaultWidth, onWidthChange | The width, controlled or uncontrolled (default 280) |
| minWidth, maxWidth | What the splitter may do (default 120, 640) |
| splitter, indent, theme, strings | The splitter, the indent of a level, its own theme (default the chart's), text |
| cellNavigation | The keys walk the cells of a row, as in the ARIA treegrid pattern; each cell gets an aria-colindex (default false) |
| onRowClick, onRowDoubleClick | (record, event), always the latest. preventDefault() keeps a click from selecting and scrolling |
| id, className, style | Of its root |
It keeps in sync what is expanded and selected, the row under the pointer or with the keyboard
focus, the vertical position, the theme, the stripes and the sizes. With layout.height: 'auto',
in one box that scrolls both, the two cannot differ; with a fixed or filling height each has its own
scroller, and the list can be a frame behind during a fast fling. A list turns on the chart's
display.rowHover: a chart without one lights no row. With selection={{ multiple: true }} on the
chart, Ctrl or Cmd + click adds a row to the selected ones, and with keyboard={{ barFocus: true }}
the bars and the rows share one focused task. The markup is the one of the vanilla list:
see its section
in the README of @ganttlane/dom, which also has the keyboard.
StrictMode, server rendering and hidden trees
- StrictMode. Mount, unmount, mount leaves one chart, no leaked listener, timer or subscription, and the instance, and its core, stay the same. The renderer is drawn into the container each time it is attached; the core is destroyed only when the component really goes: an effect cleanup cannot tell a real unmount from StrictMode's, so the core is destroyed a microtask after the cleanup, if the effects were not set up again by then.
- Server rendering. Importing the package reads no
windowordocument. A server render gives the container (<div class="…" style="…"></div>) and creates, but does not draw, the chart: nothing runs there but render. Hydration keeps the server'sdivand draws the chart into it before the browser paints.useGanttStatereads the same snapshot on the server and in the browser. The entry of the built package starts with'use client'. <Activity mode="hidden">(React 19.2). React tears effects down for a hidden tree and sets them up again when it is shown, with the state kept. The core of a chart cannot be restarted, so the chart is made again from the latest props and what the old core held: the uncontrolled tasks, links, view mode, expanded parents and selected tasks, the selected link, the zoom (zoomTo) and the date at the left edge of the viewport (the chart opens where it was scrolled to, the vertical position is not kept). Listeners added withinstance.onandinstance.core.on,useGanttStatesubscribers andinstance.core.subscribemove to the new core, andinstance.coreis the same object. What is not carried over is told to the application when the chart is shown:- An open edit session ends as a Cancel. It is rolled back (the data is what it was when
edit.begin()ran, the edits are not kept), and the application hears what a rollback tells, in its order:onHistoryChange(when there was undo history, which is not carried either),onDirtyChange({ isDirty: false })(when the session was dirty) andonEditRollbackwith the inverse, which a controlled chart writes into its tasks withpatchTaskslike any Cancel. So an "unsaved changes" flag that followsonDirtyChangeclears together with the data, and no half-open session survives. Nothing is said while the chart is hidden, and nothing when the component is really unmounted. - While hidden, the chart is gone.
ref.currentof<Gantt ref>isnull(React detaches refs of a hidden tree), and the commands of an instance you kept (select,updateTask,edit.begin, …) do nothing and are not queued; its queries (getTask,getSnapshot) answer from the state the chart had. Call them after it is shown.
- An open edit session ends as a Cancel. It is rolled back (the data is what it was when
Theme and styling
The theme, the tokens and the unstyled contract are those of the vanilla chart, because the DOM and
the stylesheet are the same: see Theme (the --gantt-* token table,
light and dark themes, per-element colours) and the
unstyled contract (the stable classes and data-*
attributes) in the @ganttlane/dom README. In short:
theme="auto" | "light" | "dark"is a prop ('auto'followsprefers-color-scheme), and changing it costs no redraw.Re-theme with custom properties set on the
.ganttelement: in a rule for.gantt(every chart), or for a descendant of your own class (.roadmap .gantt, one chart:classNamegoes to the container, the parent ofdiv.gantt). Not on the container itself: the stylesheet declares every token on.gantt, so a token set on.roadmapis not inherited by the chart, and does nothing. All of the library's CSS is in@layer gantt, so styles of your own win without!important:import { Gantt } from '@ganttlane/react'; export const Plan = () => ( <Gantt className="roadmap" defaultTasks={tasks} style={{ height: 480 }} /> );.roadmap .gantt { --gantt-bar-color: #0f766e; --gantt-today-color: #b45309; }task.classNameis added to the bar'sg.gantt__bar, andtask.color/task.progressColorset the bar's colours, so status colours are one class each.Text the chart writes itself (the tooltip's lines, the link menu, the region's label) is the
stringsprop; dates and numbers follow thelocaleprop. See Locale and strings.
Accessibility
The chart is the vanilla chart, so its accessibility is that of
@ganttlane/dom: a named, focusable scroller region (its label is
the strings.chartLabel prop), tooltips with role="tooltip" and aria-describedby, link menus with
role="menu", focus rings and prefers-reduced-motion. Bars take the keyboard focus with the
keyboard={{ barFocus: true }} prop, named with their dates, progress and working days;
keyboard={{ barFocus: true, edit: true }} lets the arrow keys move and resize the focused bar one
snap step, and says the result in a hidden live region (keyboard.announce adds the selection to
what is said), and keyboard={{ undo: true }} with history makes Ctrl or Cmd + Z undo and Ctrl or
Cmd + Shift + Z or Ctrl + Y redo. They are props like the others, off by default. Links and the handles of a bar are
not focusable, and there is no keyboard way to draw a link.
What React adds: id, aria-* and data-* props go to the container div, and the content you
draw through the slots is your React, so its
semantics, labels and focus behaviour are yours. A tooltip or menu that holds buttons or inputs is
interactive (tooltip.trigger: 'click', or a link menu): it takes pointer events, a link menu also
moves the focus to its first button, and Escape or a press outside closes it. Give users who rely on
assistive technology the same plan as a table or a list next to the chart: the
<TaskList> is a role="treegrid" that the keyboard walks.
Browser and React support
React 18.3 and 19 (peer dependencies ^18.3.0 || ^19.0.0; react-dom for portals and flushSync).
Nothing here uses an API that is only in 19: no use, no ref as a prop (forwardRef), no
useEffectEvent. Browsers as in @ganttlane/dom.
The whole unit test suite of the package runs twice, against React 19 and against React 18.3.1 (the
react and react-18 Vitest projects of the repository), and the sources and tests are type-checked
against @types/react 19 and 18. The playground page (/react/) is also run in Chromium, Firefox
and WebKit, in a production build, in one with React's development bundles (StrictMode, which makes
React report its warnings) and in one with React 18.3, and as server-rendered markup that the
browser hydrates (/react-ssr/). What differs on 18.3:
- StrictMode renders a component twice on its first render and keeps the second pass's state
(19 reuses the first pass's). The first pass of
useGanttmakes a core that is never committed and nothing holds, so it is garbage: it has no timer, listener or subscription of its own. <Activity>is 19.2 only. A chart in a hidden<Activity>is made again when it is shown (see above); on 18.3 there is no such component.- A server render that has a
document(a test with jsdom or happy-dom, not a server) makes React 18 report thatuseLayoutEffectdoes nothing on the server. The component usesuseEffectwhere there is nodocument, so a server proper never sees it; render withdocumentandwindowhidden, or ignore the message in such a test. React 19 does not report it. - Type packages. With
@types/react18,useRef<GanttHandle>(null)is a read-onlyRefObject<GanttHandle>and with 19 aRefObject<GanttHandle | null>; both are a validref.
Size
@ganttlane/react adds about 8 kB (min + gzip) to what an application already ships of
@ganttlane/core and @ganttlane/dom (budget 10 kB), React excluded.
Limits and roadmap
Limits you should know about today:
- There is no toolbar component and no React Aria integration yet. What exists is
<Gantt>,<GanttRoot>with<GanttChart>,<TaskList>, the hooks, therefhandle and the three slots. The layers of the chart (header, grid, bars, links) are not components: the vanilla renderer draws them as one skeleton. - A hidden
<Activity>takes the chart away and brings a new one back: uncontrolled data, view mode, expanded parents, selection and zoom are carried over, but an open edit session ends as a Cancel and the undo history is cleared (see StrictMode, server rendering and hidden trees). - A React label does not have a measurable width, so
display.barLabel: 'auto'puts it inside the bar; choose'inside'or'outside'. Each visible bar's label is one portal. - The server renders the container only; the chart is drawn after hydration. A
<TaskList>renders its first rows. - Controlled
tasksneedonTasksChangewhile editing is on (development warns), and a new array with equal contents ends a pending draft. - The limits of the renderer and the core apply too: left to right only, no toolbar, no zoom
gestures, floating time without time zones,
and so on. See
@ganttlane/domand@ganttlane/core.
Examples
examples/ has a basic chart,
controlled data,
an edit session with Save and Cancel,
a custom tooltip and
a release plan, each with a react.tsx. They run live on the
documentation site, next to their code. The playground page /react/
(apps/playground/src/react-app.tsx) is the whole
library in one page: view and edit mode, Save and Cancel with a changes panel, theme and locale as
props, the ReactNode slots and an event log; run it with pnpm dev in the repository.
