@ganttlane/dom
v0.5.0
Published
Interactive gantt chart: vanilla DOM renderer and default CSS theme on top of @ganttlane/core.
Maintainers
Readme
@ganttlane/dom
An interactive gantt chart for the browser: a vanilla DOM renderer and a default CSS theme on top of
@ganttlane/core. createGantt(container, options) draws the chart, handles
pointer, touch, keyboard and scroll input, and returns an object with methods and events. It needs
no framework (it is what @ganttlane/react wraps), ships a stylesheet you can
use as it is, re-theme with CSS custom properties, or replace entirely, and draws only what is on
screen, so plans of thousands of tasks stay smooth.
Documentation: the documentation site has the guides, live examples next to their code (vanilla and 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
- Without a bundler
- Quick start
- Concepts
- Options
- Events
- Methods
- Issues
- Tooltips, the link menu and day labels
- Task list
- Locale and strings
- Theme
- Unstyled contract
- Behaviour
- Bindings and building blocks
- Accessibility
- Browser support
- Size
- Limits and roadmap
- Examples
Install
# pick one
pnpm add @ganttlane/dom
npm install @ganttlane/dom
yarn add @ganttlane/dom
bun add @ganttlane/dom@ganttlane/domdepends on@ganttlane/core, so this one install is enough to draw a chart. ESM only, with TypeScript declarations; TypeScript must resolve packageexports(moduleResolutionofbundler,node16ornodenext).- Strict pnpm lets you import only what your own
package.jsonlists. The types of the core that a signature of this package names, however deep (Task,Link,TaskRecord,ChangeSet,Issue,GanttCore,GanttEventMap, and the results ofupdateTask,addLink,removeLinkandundo, the payloads of the events, the option groups, the before-hooks, …) are re-exported by this package, types only, soimport type { Task, ChangeSet, LinkCreateResult } from '@ganttlane/dom'works. Runtime values of the core are not: add@ganttlane/core(the same version) to importpatchTasks(controlled data),createGanttCore(a core of your own formountRenderer) or the building blocks. - The stylesheet is a separate file,
@ganttlane/dom/styles.css. The JavaScript never imports it, so bundlers, CSS-in-JS setups and plain<link>tags all work. Import it once per page. With TypeScript 6, which checks side-effect imports (noUncheckedSideEffectImportsis on by default),import '@ganttlane/dom/styles.css'type-checks as it is: the subpath ships an (empty) declaration file. The CSS is in@layer gantt, so any style of your own wins without!important. - Have the stylesheet in effect before you mount: the chart measures its container when it is
created, and a stylesheet that is still loading (WebKit may run a module script first) gives it the
unstyled layout to measure. A position the chart could not take for that reason is taken again
when the scroller is resized, but a date aligned to the middle of the viewport (
align) was centred on the unstyled width.
Without a bundler
A page with no build step can load the package from a CDN. VERSION below is the version you
tested: a range such as 0.4 (a minor version, while the packages are 0.x) or 1 (a major one,
from 1.0) takes fixes and never a breaking change.
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@ganttlane/dom@VERSION/dist/styles.min.css"
/>
<script src="https://cdn.jsdelivr.net/npm/@ganttlane/dom@VERSION/dist/ganttlane.global.prod.js"></script>
<div id="chart" style="height: 420px"></div>
<script>
Ganttlane.createGantt(document.getElementById('chart'), { tasks: [] });
</script>dist/ganttlane.global.prod.jsis minified (short issue messages) and sets one global,Ganttlane, with everything this package exports pluscreateTaskListandfromFrappeGanttOptions. It needs nothing else: the core,@internationalized/dateandvalibotare inside.https://cdn.jsdelivr.net/npm/@ganttlane/domand unpkg serve it when you give no file.dist/ganttlane.global.jsis the readable build, with the full issue messages and the hints for old option names. Use it while you write the page or move from frappe-gantt.dist/styles.min.cssisstyles.cssminified (3.6 kB gzipped).dist/THIRD-PARTY-LICENSES.txtholds the licences of the code inside the scripts.- The files are not in
exports: a bundler never picks them up, andimport '@ganttlane/dom'is unchanged. If you have a bundler, use the package: the script is about 270 kB (90 kB gzipped) because it holds the whole library with its calendar, where frappe-gantt's file is 25.6 kB. - To load the package as a module instead, import it in a
<script type="module">fromhttps://cdn.jsdelivr.net/npm/@ganttlane/dom@VERSION/+esm(orhttps://esm.sh/@ganttlane/dom@VERSION). - For a page that must not change, name the exact version and add
integrity="sha384-…"andcrossorigin="anonymous"to the tags; SRI cannot be used with a range. Compute the hash from the file the CDN serves:curl -s <url> | openssl dgst -sha384 -binary | openssl base64 -A.
Quick start
<div id="chart" style="height: 420px"></div>import '@ganttlane/dom/styles.css';
import { createGantt } from '@ganttlane/dom';
// What `task.data` holds: it is the type of `record.task.data` in every event and render function.
interface Meta {
owner: string;
}
const container = document.querySelector<HTMLElement>('#chart');
if (!container) throw new Error('#chart not found');
const gantt = createGantt<Meta>(container, {
tasks: [
{
id: 'design',
name: 'Design',
start: '2026-03-02',
end: '2026-03-06',
progress: 60,
data: { owner: 'Ann' },
},
{ id: 'build', name: 'Build', start: '2026-03-09', end: '2026-03-20' },
],
links: [{ source: 'design', target: 'build' }],
viewMode: 'week',
scroll: { initial: 'first-task' },
theme: 'auto',
tooltip: { delay: 300 },
onTasksChange: (changeSet) => {
console.log(changeSet.reason, changeSet.changes.length);
},
});
// Later, without recreating the chart: options, view, scrolling, data, events.
gantt.setOptions({ theme: 'dark', locale: 'uk' });
gantt.setViewMode('month');
gantt.scrollToTask('build');
const off = gantt.on('taskClick', (record) => {
console.log(record.task.data?.owner);
});
// The edit session ("Edit mode: Save / Cancel"). Changes are applied as they happen (`getTask()`
// shows them, `tasksChange` fires); `rollback()` puts back the data that `begin()` found.
gantt.edit.begin();
// ... the user drags bars ...
gantt.edit.getChanges(); // the net difference from the data at `begin()`
gantt.edit.commit(); // closes the session and returns that difference; or gantt.edit.rollback()
// Removes every node, listener, observer and timer the chart added, then destroys its core.
// Safe to call twice.
off();
gantt.destroy();- The container needs a height: the chart fills it and scrolls inside it. Or give the chart its
own with
layout.height: a number of px, or'auto'to let it grow with its rows and the page scroll it (see Height). The container should be empty; the chart adds onediv.ganttto it and removes it ondestroy(). - The container is an element.
createGanttthrows aTypeErrorwhencontaineris not an element (a selector string,null,undefined); nothing else throws. A bad option or bad data is an issue and its default is used. (An error that one of your own handlers throws is reported as Events describes.) - Some handlers run before
createGanttreturns. The chart reports its first range and scroll position while it mounts, soonRangeChangeandonScrollare called insidecreateGantt, andonIssuesis when the first options or data have issues. A handler that reads theganttvariable finds it unset: see Handlers that run during creation. - It needs a DOM.
createGanttmeasures and draws into the element, so it is for the browser. On a server import types only (import type { Task } from '@ganttlane/dom'), or use@ganttlane/core, which has no DOM.@ganttlane/reactrenders its container on a server and draws the chart after hydration. - Open on your plan.
scroll.initialis'today'by default. A plan in the past or the future then opens at the nearest end of its range; use'first-task'or'start'for fixed dates. - Your options, tasks and links are never mutated, and a value that cannot be used never throws: it is an issue and its default is used.
More complete, runnable examples (basic, controlled data, an edit session with Save and Cancel,
working days at hourly granularity, a custom tooltip, a release plan; each in vanilla and in React)
are in examples/, one folder each, and run live, next to their code, on the
documentation site; the playground (pnpm dev, see
the root README) is the demo of everything.
Concepts
The data model, the scheduling rules and the edit flow are the core's. This is the short version;
the details are in @ganttlane/core.
- Tasks and links. A task has an
id, aname, astartand anend(or aduration); a link joins asourceto atargetwith a type (FS,SS,FF,SF) and a lag. Tasks nest throughparentId, and a child can be pinned to its parent's edges. The chart never changes your objects: the computed form of a task is aTaskRecord(gantt.getTask(id), or the argument of an event) withDates, lengths, working days, progress,issuesandconflicts. - Dates are floating wall-clock time.
'2026-03-02'is a day (its end is inclusive: the last day),'2026-03-02T09:30'a moment. There are no time zones, and strings withZor an offset are rejected. ADateis read through the local clock. - Granularity and alignment.
scheduling.granularity(default'1d') is the data step: every date, duration and lag is a multiple of it. For planning inside a day set'1h','30min', … Input off the step is reported (misaligned) and aligned byscheduling.alignInput. - Work calendar.
calendar(weekends,holidays,workingDays,isWorkingDay) decides which days work;durationMode: 'working'counts durations in working time, and drags land on working days (scheduling.skipNonWorkingDays). - Propagation. An edit moves the tasks linked to it, and the children pinned to it, keeping their
lags (
scheduling.propagation). The result is oneChangeSetwith every task touched, and itsconflicts(a bar on a day off, a child outside its parent, a violated link). - Controlled and uncontrolled data. By default the chart owns the tasks: a drag changes them and
onTasksChangetells you. WithdataMode: 'controlled'you own them: every committed change is reported, shown as a draft, and applied when you pass the new tasks back withsetTasks;patchTasks(tasks, changeSet, gantt.core.getSnapshot().state.derived.model)from@ganttlane/corewrites a change set into your own tasks in their own shape (a short example has the whole loop). With React thetasks/defaultTasksprops make the choice, see@ganttlane/react. - Edit sessions and history.
gantt.edit.begin(),commit()androllback()are the "Edit, Save, Cancel" flow. A session does not hold changes back: they are applied as they happen (getTask()shows them,tasksChangefires).begin()remembers the data,edit.getChanges()is the net difference from it,commit()closes the session and returns that difference, androllback()puts the remembered data back. Undo and redo needhistory: true: thenundo()andredo()step through one entry per action, inside a session too. A before-hook,onBeforeTaskChange, can cancel or rewrite a change, also after asking the user. - Issues. Bad options and bad data are reported, repaired and never thrown. See Issues.
Options
createGantt(container, options) takes one flat object: the core's options and the renderer's.
gantt.setOptions(patch) changes any of them later without recreating the chart.
| Option | Goes to | What it is |
| ---------------------------------------------------------------------------- | ------------ | --------------------------------------------------------------------------------------------------- |
| tasks, links, dataMode, dates, calendar, scheduling, hierarchy | the core | The data and the rules that schedule it |
| viewMode, viewModes, locale, now, timeline, scroll, direction | the core | The view: scale, range, scrolling |
| layout (but height), editing, selection, history, interaction | the core | Sizes, editing from the UI, undo, gestures |
| on<Event>, onBeforeTaskChange, onBeforeLinkCreate | the core | One handler per event (see Events); gantt.on adds more |
| onIssues | the chart | Problems with the data and the options, the renderer's included (see Issues); a function |
| theme | the renderer | 'auto' (default), 'light' or 'dark' (see Themes) |
| strings | the renderer | The words of the chart (see Locale and strings) |
| layout.height | the renderer | 'auto', a number of px, or the container's (see Height) |
| display, highlights, tooltip, linkMenu, highlightTooltip | the renderer | Grid lines, today line, non-working days, bar labels, … and popovers (see below) |
| toolbar, direction: 'rtl' | nobody yet | Planned: accepted and reported as unsupported-option |
| renderer: { idPrefix, layers, frames } | the renderer | How the renderer is mounted (stable ids, replacement layers, a frame source); at creation only |
The core's options (the first four rows) are listed with their types and defaults in
@ganttlane/core: tasks, links, dataMode, dates.endInclusive,
calendar.*, scheduling.* (granularity, alignment, snap, propagation, durations),
hierarchy.*, viewMode / viewModes, locale, now, timeline.* (range, padding, infinite
timeline), scroll.* (initial position, behaviour, alignment, drag autoscroll), layout.* (row,
bar and header sizes), editing, selection, history and interaction.*.
editing switches editing from the UI on and off, in whole (false is a view-only chart: no
drag, resize, progress change or link drawing) or by kind ({ move, resize, progress, links }), and
task.editable overrides it per task. It does not stop your own calls: updateTask, addLink and
removeLink still commit (setTasks is no change of the chart's, see Methods).
history is off until you set history: true (or
{ limit }, default 100): it records one entry per action for undo() and redo().
selection is what a click does to the selected tasks: onClick selects the bar that is clicked,
clearOnEmptyClick clears them on a click on an empty part of the chart, and multiple makes Ctrl
or Cmd + click add the task or take it out. All are off by default.
keyboard.barFocus lets bars take the keyboard focus, keyboard.edit lets the arrow keys move and
resize the bar that has it, keyboard.announce says selection changes in a live region, and
keyboard.undo makes Ctrl or Cmd + Z undo and Ctrl or Cmd + Shift + Z or Ctrl + Y redo (see
Keyboard focus); all four are off by default too.
Defaults: locale is 'en', not the browser's, so that a server and a browser draw the same text;
theme is 'auto'; scroll.initial is 'today'; timeline.infinite is off. Keys nobody knows are
ignored, your options, tasks and links are never mutated, and a value that cannot be used never
throws: it is an issue, and its default is used.
TData is the type of task.data. It flows through tasks, the records of every event and
query, and the context of tooltip.render, linkMenu.render and display.renderBarLabel; without a
type argument it is inferred from tasks.
Renderer options
| Option | Type / values | Default | Meaning |
| ------------------------------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| theme | 'auto' | 'light' | 'dark' | 'auto' | The data-theme of the chart; changes at runtime without a redraw |
| strings | partial GanttStrings | English | Every text the chart writes itself: Locale and strings |
| layout.height | number | 'auto' | the container's | Height: a number of px, or as tall as the rows with the page scrolling |
| display.gridLines | 'both' | 'vertical' | 'horizontal' | 'none' | 'both' | Which grid lines the body draws |
| display.today | boolean | true | The today line, and its marker and highlighted cell in the header |
| display.nonWorkingDays | 'fill' | 'stripes' | false | 'fill' | How days off (weekends, holidays) are shown |
| display.highlights | boolean | true | Whether the highlights below are drawn |
| display.rowHover | boolean (see Task list) | false |
| display.rowStripes | 'row' | 'group' | false | false | Zebra rows, per row or per group (a parent with its children) |
| display.barLabel | 'auto' | 'inside' | 'outside' | 'none' | 'auto' | Where the label goes; 'auto' puts it inside when it fits, else outside |
| display.renderBarLabel | (context) => Node \| DisposableNode \| string \| null | the name | Custom label content: Tooltips has the node rules |
| display.headerRangeHighlight | boolean | true | Highlights the hovered bar's range in the header |
| display.linkCurveRadius | number (px) | 5 | The largest corner radius of an arrow |
| display.stickyBarLabels | boolean | false | Reserved: true is an unsupported-option and draws as off |
| display.expectedProgress | boolean | false | Reserved: the same |
| highlights | { id, dates?, range?, match?, label?, className?, color? }[] | none | Highlighted days, visual only: they change no schedule (details below) |
| tooltip | { trigger, followCursor, showWhileDragging, render, delay, placement } | 'hover', false, true, built in, 150, 'top' (a field left out takes its default; setOptions takes the group whole) | The task tooltip: Tooltips |
| linkMenu | { render, onRemove } | a remove action; onRemove is removeLink | The menu a click on an arrow opens |
| highlightTooltip | { followCursor } | { followCursor: false } | The label of a highlighted day or a holiday |
| toolbar | false | { todayButton, viewModeSelect, zoom } | false | Reserved: enabling it is an unsupported-option; draws nothing |
| renderer.idPrefix | string | gantt-1, gantt-2, … | Prefix of every element id and the id of the root; give your own for ids that repeat across renders (server rendering) |
| renderer.layers | { [name]: factory } | built in | Replaces built-in layers by name (advanced: custom renderers and tests) |
| renderer.frames | { request, cancel } | requestAnimationFrame | Where frames come from; mainly for tests |
A highlight marks days without touching any schedule: dates are single days, range is
{ start, end } with both ends inclusive, match is a predicate of a calendar day ({ dayKey, date, weekday, year, month, day }),
and they may be combined; label (text, or a function of the day) shows when the day is hovered,
className is added to the highlight's nodes and color sets --gantt-highlight-color on them. A
holiday of the work calendar shows its name the same way.
display is merged field by field with what was set before, and undefined puts a field back to
its default; the popover groups tooltip, linkMenu and highlightTooltip and the list
highlights are taken whole (see setOptions under Methods).
Events
Every event has an on<Event> option and a listener added with gantt.on(name, handler), which
returns a function that removes it; both take the same arguments. Payloads that carry a task are the
TaskRecord (with Dates); a ChangeSet carries the core's floating numbers so it can be handed
back to the core and to patchTasks.
| Event | Arguments | When |
| -------------------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| tasksChange | (changeSet) | A change committed: one per action, with every task it touched. Also undo and redo |
| taskDatesChange | (record, { start, end, endExclusive }) | A convenience after tasksChange, per task whose dates changed |
| taskProgressChange | (record, progress) | The same for progress |
| linkCreate | (link, id) | A link was created (the id may be …#2 if the default one was taken) |
| linkDelete | (linkRecord) | A link was deleted |
| linkClick | (linkRecord, event) | An arrow was clicked; it is now the selected link and its menu opens |
| taskClick | (record, event) | A bar was clicked (never after a drag) |
| taskDoubleClick | (record, event) | A bar was double-clicked |
| taskHoverChange | (record \| null, event) | The task under the pointer changed |
| selectionChange | (selectedKeys, selectedLink) | The selected tasks (by select()) or link (by a click) changed |
| viewModeChange | (viewMode) | The view mode changed |
| rangeChange | ({ start, end, reason }) | The timeline range changed: for lazy loading by date. Also once at start ('initial') |
| rowsChange | (rows) | What getRows() returns changed: a group opened or shut, a name, committed dates. Not during a drag preview |
| rowHoverChange | (record \| null) | The lit row changed: the pointer's row in the chart, or setHoveredRow |
| focusChange | (record \| null) | The task whose row or bar has the keyboard focus changed (focusTask) |
| expandedChange | (expandedKeys, expandedIds) | expand, collapse, expandAll or collapseAll changed the expanded parents |
| scroll | ({ x, y, date }) | The scroll position changed (and once at start); date is at the left edge of the viewport. Also for a scroll the core requests itself (the initial scroll, an instant scrollTo*): that one is reported at once, before the renderer has performed it |
| editRollback | (sessionChanges) | A session was cancelled: the inverse for controlled data to apply |
| dirtyChange | ({ isDirty }) | edit.isDirty flipped |
| historyChange | ({ canUndo, canRedo }) | What undo and redo can do changed |
| editRefused | ({ gesture, id, target?, reason, at }) | A drag or a link was refused: 'busy', 'permission', 'unknown-task', 'self', 'cycle', 'duplicate' |
| issues | ({ source, issues }) | The issues of the data or the options changed (see Issues) |
| error | ({ error, source: 'listener' \| 'hook', name }) | A listener or a before-hook threw |
The option names are onTasksChange, onTaskDatesChange, onTaskProgressChange, onLinkCreate,
onLinkDelete, onLinkClick, onTaskClick, onTaskDoubleClick, onTaskHoverChange,
onSelectionChange, onViewModeChange, onRangeChange, onExpandedChange, onRowsChange,
onRowHoverChange, onFocusChange, onScroll, onEditRollback, onDirtyChange,
onHistoryChange, onEditRefused, onIssues and onError.
expandedChange gives what the chart keeps ('all', or a list that may name tasks without
children) and, as its second argument, the parents that are expanded now, always as a list. One more
event has no option: gantt.on('appearanceChange', (appearance) => …) fires when getAppearance()
changes.
Two before-hooks are options only. onBeforeTaskChange({ changeSet, reason, conflicts }) runs before
a change is committed (a drag, updateTask): return false to cancel it, a ChangeSet to
replace it (it is re-validated and re-aligned), or nothing (or true) to let it pass; it may be
async, for a confirmation (a function that returns Promise<boolean | void> is a hook).
onBeforeLinkCreate({ link, id, wouldCreateCycle }) returns false to
refuse a link; a link that would close a cycle is refused whatever it answers. A hook that throws or
rejects cancels the change and reports to error. Rolling back an edit session and stepping through
the history never ask the hooks.
A handler that throws does not break the others or the chart: the error goes to onError when you
have one. With no error listener it depends on what caused the event:
- A call of yours (
updateTask,addLink,removeLink,select,setTasks,setOptions,setViewMode,scrollTo*,edit.*,undo, …, andcreateGanttitself foronIssues) rethrows what the handlers threw, once the call is done and the change has happened: one error as it is, several as anAggregateError. A plain method throws synchronously; the async ones (updateTask,addLink,removeLink) reject their promise, soawait gantt.updateTask(…)inside atrysees the error of youronTasksChange. - The chart's own input (the pointer, a scroll, a resize, the clock: what the renderer reports
to the core) cannot throw into the browser's event loop, so the error goes to the host's
unhandled-rejection channel instead (the console, your error tracker, an
unhandledrejectionlistener).
Handlers that run during creation
A handler given as an option can run before createGantt returns: the chart reports its first
range and scroll position while it mounts, so onRangeChange (reason 'initial') and onScroll
always are (a container with no width yet, such as a hidden one, waits for its first resize), and
onIssues is when the first options or data have issues. A handler that reads the gantt variable
would find it unset (a const is in its temporal dead zone, a let is undefined). Declare it
first and guard, or register the handler once the chart exists:
import { createGantt, type Gantt } from '@ganttlane/dom';
// `container` and `tasks` as in the quick start.
let gantt: Gantt | undefined;
gantt = createGantt(container, {
tasks,
// Runs inside `createGantt`: `gantt` is not assigned yet.
onRangeChange: ({ start, end }) => {
gantt?.scrollToDate(start); // guarded
},
});
// Or add the listener afterwards: it hears everything from then on.
const off = gantt.on('rangeChange', ({ reason }) => console.log(reason));The events of creation are not replayed to a listener added later (the first issues are always
there to read with gantt.getIssues()). @ganttlane/react holds them back instead: they are
delivered once the component is committed, so a handler there can use the ref (see
How props reach the chart).
Methods
All of them are on the object createGantt returns and need no this. After destroy() the
commands (everything but the queries and core) do nothing, and the queries (getTask, getLink,
getVisibleRange, getRowMetrics, getBarRect, getIssues, canUndo, canRedo) keep answering
from the state the chart had when it was destroyed, so that a clean-up can read what the user left
behind.
| Group | Methods | Returns |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Data | setTasks(tasks), setLinks(links) | nothing |
| | updateTask(id, { start?, end?, progress? }) | a promise of { status }: 'committed' (with changeSet), 'unchanged', 'rejected' (reason 'unknown-task' or 'invalid-value'), 'cancelled' (reason 'hook', 'error' or 'disposed') or 'busy' |
| | nudge(id, gesture, direction) | a promise of the commit's result, or { status: 'refused', reason }: one snap step of 'move', 'resize-start' or 'resize-end', -1 or 1, what an arrow key does |
| | addLink(link), removeLink(id) | a promise of { status: 'created' \| 'deleted' }, { status: 'rejected', reason }, { status: 'cancelled', reason: 'hook' \| 'error' \| 'disposed' } or { status: 'busy' } |
| Options | setOptions(patch) | the issues of the options now in use |
| View | setViewMode(id, { keepScroll }), zoomTo(pxPerDay, { anchor }) | nothing |
| Scrolling | scrollToDate(date, { align, behavior }), scrollToToday(options), scrollToTask(id, { align, alignY, behavior, expand }) | nothing |
| Rows | expand(id), collapse(id), toggle(id), expandAll(), collapseAll(), setHoveredRow(id), focusTask(id), focusBar(id) | nothing |
| Selection | select(ids), selectLink(id), clearSelection() | nothing |
| Queries | getTask(id), getLink(id), getVisibleRange(), getRowMetrics(id), getBarRect(id), getRows(), getRowWindow(), getLayoutMetrics(), getAppearance(), getIssues() | a TaskRecord, a LinkRecord, { start, end }, row metrics, bar geometry (in body px, a drag in progress included), { data, options } |
| Popovers | hideTooltip() | nothing |
| Edit session | edit.begin(), edit.commit(), edit.rollback(), edit.getChanges(), edit.isOpen, edit.isDirty | { status } (synchronous): 'begun' or 'already-open'; 'committed' or 'rolled-back' (with changes); 'no-session', 'busy' or 'cancelled'. The session's SessionChanges, booleans |
| History | undo(), redo(), canUndo, canRedo (need history: true) | { status }: 'undone' or 'redone' (with the entry), 'empty', 'stale', 'busy' or 'cancelled' |
| Events | on(event, handler) | a function that removes the listener |
| Life cycle | destroy(); core | core is the chart's GanttCore, for what the facade leaves out |
- The view methods return nothing.
setViewMode,zoomToand thescrollTo*methods make the chart scroll in its next frame; the request that the core makes for the renderer is not yours to apply (gantt.core.scrollToTask(…)returns it, and the renderer still applies it).scrollToTaskscrolls both axes and expands the collapsed parents of a hidden row unlessexpand: false. Itsalign(sideways) andalignY(vertically) also take'nearest', the least scroll that shows it and none when it is in view, andfalse, which leaves that axis alone.scrollTo({ left, top, behavior })goes to a position on either axis or both:topis in body pixels (the first row is at0), and both are clamped to the content. AscrollTo*made before the chart has a width is not lost: it replacesscroll.initialwhen the chart gets one. - For what stands beside the chart.
getRows()is the visible rows in order, each withid,index,name,depth,hasChildren,isExpanded,rootId, the stripes,setSize,positionandrecord; the array and the rows keep their identity until something in them changes, so comparing them says whether to draw again. It costs O(rows) after such a change, about 15 ms for 10 000 tasks, and only when it is read.getRowWindow()says which rows the chart draws (firstRow,lastRow),getLayoutMetrics()its sizes (headerHeight,rowHeight,barHeight,bodyHeight,contentWidth,viewportWidth,viewportHeight) andgetAppearance()itstheme,height,rowStripes,rowHoverandmultiSelect.setHoveredRow(id)lights a row from outside the chart (snapshot.hoveredRow), andfocusTask(id)says which task's row or bar has the keyboard focus (snapshot.focusedTask). The task list is built on these and nothing else, and you can build your own on them. updateTask(id, { start, end, progress })is a change from code. It propagates exactly like the drag it stands for (startalone moves the task,endalone moves its end edge, both set both) and runsonBeforeTaskChangewithreason: 'api'. Values are exact (no snapping to the view mode and no rolling onto working days), and a value off the granularity followsscheduling.alignInput. A locked task is'unchanged'; an unknown task or an end that is not after the start is'rejected'(reason'unknown-task'or'invalid-value', withissues); a before-hook that answersfalsemakes it'cancelled'withreason: 'hook', one that throws, rejects or answers something else withreason: 'error'; while a controlled change waits for yoursetTasksthe chart is busy and the answer is'busy'. Afterdestroy()it resolves{ status: 'cancelled', reason: 'disposed' }.addLinkandremoveLinkcheck the link first (shape, type, lag), then that both tasks exist, that it is not a duplicate and that it closes no cycle, and runonBeforeLinkCreatefor a new one. A refusal is{ status: 'rejected', reason }withreason'invalid','unknown-task','duplicate','cycle'or, forremoveLinkwith an id that is no link,'unknown-link'. Two more answers:{ status: 'cancelled', reason }, wherereasonis'hook'(onBeforeLinkCreateansweredfalse),'error'(it threw, rejected or answered something else than a boolean) or'disposed'(the chart was destroyed, the only wayremoveLinkis cancelled), and{ status: 'busy' }while another change waits for a hook or, with controlled data, for yoursetTasks.editingdoes not apply to calls from code.editing: falseturns off editing from the UI (drags, link drawing, the menu's remove action), so a read-only chart cannot be edited by its user;updateTask,addLinkandremoveLinkstill commit. Alockedtask is the oneupdateTaskleaves alone.setTasks(tasks)andsetLinks(links)are not changes of the chart's. They hand it new data, as if you had created it with them: diffed by id, with no before-hook, notasksChangeand no history entry. With uncontrolled data they clear the undo history (its entries describe data that is gone); with controlled datasetTasksis your answer to a change, which ends its draft (seedataMode). LikeupdateTask, they ignoreediting.setOptions(patch)changes the chart without recreating it. A key left out keeps its value, one set toundefinedgoes back to its default, and each key is routed to what depends on it and nothing else: core options to the core (which recomputes only what they invalidate),theme,strings,layout.heightand the display options to the renderer's own setters. Swapping anon<Event>handler is O(1) and redraws nothing. The groups the core and the renderer know (calendar,dates,scheduling,hierarchy,layout,timeline,scroll,interaction,display,strings) are merged one level deep with what was set before: a field of the group is replaced, and a field that is itself an object or a list (scheduling.skipNonWorkingDays,scheduling.snap,timeline.range,timeline.infinite,scroll.autoScroll,interaction.handles,calendar.holidays, …) is replaced whole, so the keys it leaves out are back at their defaults. The popover groupstooltip,linkMenuandhighlightTooltip,editingandhistoryas objects,viewModesand every list (tasks,links,highlights, …) are taken whole too: what they leave out is back to its default.setOptions({ tasks: undefined })is not a reset but aninvalid-optionissue, sincetaskshas no default (the chart then shows no tasks; passtasks: []to empty it on purpose). It returns the issues of the options now in use.select(ids)selects tasks (ids that are not in the data are left out) andselectLink(id)an arrow; clicking an arrow selects it too. Clicking a bar emitstaskClickand selects no task unlessselection.onClickis on (withselection.multiple, Ctrl or Cmd adds the task or takes it out); like a click on an empty place of the chart, it clears the selected link (the selected tasks stay, unlessselection.clearOnEmptyClickis on).- Events are the same whether you give an
on<Event>option or callgantt.on: the payloads of Events, which the facade does not change.
Issues
The core checks its options and its data; the renderer's options are checked the same way, and
their problems join the core's in one list. A value that cannot be used is an invalid-option
with the full path of the value (tooltip.trigger, display.gridLines, layout.height,
highlights[1].dates, strings.removeLink, onIssues, …), and the option's default is used.
suggested carries that default when it is a plain value ('both', 150, 'auto', true);
there is none for what has no default to name: functions (tooltip.render, linkMenu.onRemove,
display.renderBarLabel, onIssues), strings (strings.*), layout.height (the container's)
and the items of highlights. A highlight whose id is missing or repeats an earlier one is
left out (highlights[2], highlights[2].id); a field of the wrong kind (dates that is no
array, a range without both ends, a match that is no function, a label that is neither a
string nor a function, a className or color that is no string) is dropped and the highlight
stays. A value planned for a later release (toolbar when it is on, direction: 'rtl',
tooltip.trigger: 'focus', display.stickyBarLabels and display.expectedProgress when true)
is an unsupported-option (so is the core's scheduling.propagation: 'push-forward'). All of it is
in getIssues().options, in what setOptions returns and in the issues event (onIssues or gantt.on('issues', …),
source: 'options'), which fires when the list changes, also when the chart is created. A
listener that throws does not stop the others; its error goes to onError when the core's issues
changed, as for every event, and is rethrown by setOptions (or createGantt) when only the
renderer's changed. The messages of the renderer's own issues are always the short form
'<path>: <code>' (the renderer has no development build): read path, value and suggested.
The core's issues have full explanations under the development export condition, see
@ganttlane/core.
Tooltips, the link menu and day labels
Popovers are one div.gantt__popover at a time inside div.gantt__layer, a sibling of the
scroller, so they are never clipped by it or scrolled away. They are the tooltip, linkMenu
and highlightTooltip options (and highlights, whose labels they show), each taken whole by
setOptions:
import { createGantt } from '@ganttlane/dom';
// `container`, `tasks` and `links` as in the quick start.
const gantt = createGantt(container, {
tasks,
links,
tooltip: {
trigger: 'hover', // 'hover' (default) | 'click' | 'none' ('focus' is reserved)
followCursor: false, // sit above the cursor and follow it, instead of on the bar
showWhileDragging: true, // keep it during a drag, showing the previewed dates
delay: 150, // ms before a hovered bar's tooltip opens
placement: 'top', // 'top' (default) | 'bottom'; flips when there is no room
render: (context) => ({ title: context.task.name, details: '…' }), // optional
},
// `onRemove` is the chart's own `removeLink` unless you give yours (to confirm, or to change
// your own data in a controlled setup).
linkMenu: {
onRemove: (link) => {
if (window.confirm('Remove this dependency?')) void gantt.removeLink(link.id);
},
},
highlightTooltip: { followCursor: false },
highlights: [{ id: 'launch', dates: ['2026-03-04'], label: 'Launch day' }],
});
gantt.hideTooltip(); // closes the popover that is openThe options of the three groups and their defaults (a field left out, or of the wrong kind, takes the default, which an issue then suggests when it is a plain value):
| Option | Default | Meaning |
| ------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| tooltip.trigger | 'hover' | What opens it: 'hover', 'click' or 'none' ('focus' is reserved and opens nothing) |
| tooltip.followCursor | false | It sits above the cursor and follows it, instead of sitting on the bar |
| tooltip.showWhileDragging | true | It stays during a drag and shows the previewed dates (trigger: 'none' shows none) |
| tooltip.render | the built-in content: name, dates, progress, working days | The content (see below); null shows nothing |
| tooltip.delay | 150 | Milliseconds a hovered bar waits before its tooltip opens, 0 for none; a hovered highlighted day waits the same |
| tooltip.placement | 'top' | The side of the bar (or the cursor) it prefers, 'top' or 'bottom'; it flips when there is no room |
| linkMenu.render | a menu with one action, remove, when the link can be removed | The content of the menu a click on an arrow opens; null shows no menu |
| linkMenu.onRemove | the chart's own removeLink | What the remove action calls, with the link's record |
| highlightTooltip.followCursor | false | The label of a highlighted day or a holiday follows the cursor, instead of staying where it opened |
Content is safe by type.
render(andlinkMenu.render) return astring(inserted as text, never parsed), aNode(appended as it is),{ html }(the one way to insert markup: parsed as it is, so sanitising it is your responsibility) or{ title, subtitle, details, actions }(text, plus buttons that callonSelect(context)and close the popover).null,'', whitespace,{}, a title of whitespace and markup that is only whitespace in elements ({ html: '<div> </div>' }) show nothing. Withoutrender, a task tooltip shows the name, the dates in the chart's locale, the progress and the working days; during a drag it shows the previewed dates and working days.Content that has a lifetime. A
Nodethat someone else keeps alive (a framework's portal host, a widget that has to be torn down) is returned as{ node, dispose }(DisposableNode):nodeis shown as any node is, anddispose()is called once when the renderer is done with it: the popover closes (a press outside, Escape,api.hide(), the hover leaving,destroy), is replaced by another task's, or is made again from new inputs. When the popover is made again the new content is asked for before the old is disposed, so the two may share a node (a framework can keep the node and its state, and count its uses); return a new{ node, dispose }from every call. A node that is already the only child of the popover stays where it is when the popover is made again: it is not taken out and put back, so the focus and the selection in it (an<input>the user is typing in) survive, and a popover that held the focus leaves it there as long as the new content still holds it (otherwise it goes to the first button, as before). Two more fields are optional and for popovers:attached()is called once the popover is in the chart and before it is measured, each time the content is shown (opened or made again). Draw here what needs the document: a framework's synchronous render (autoFocus, layout effects that measure), a widget that reads its size. What the content draws or focuses here counts: the popover is judged empty after it, and a menu does not move the focus to its first button when the content has taken it.owns(target)says whether a node belongs to the content though it is not insidenode: what it renders elsewhere in the page (the portal of a select, a menu or a date picker). A press on it is not a press outside (which closes a popover that stays open) and a scroll of it does not close the popover. It is called with any node, must not read layout, and the one of the content shown now counts.
Content that shows nothing opens no popover and is disposed at once: for a
DisposableNodethat is no text but whitespace and no element that draws without text (img,svg,canvas,video,iframe,hr,progress,meterand the controls), the rule{ html }has;showsNothing(element)of@ganttlane/dom/dom-utilsis that rule. A plainNodeis shown as it is, however empty it looks. The node should have its content when it is returned, or draw it inattached; the popover measures it after. None of the functions may throw. It takes pointer events when the popover stays open (a link menu, a tooltip opened bytrigger: 'click'), so that what is in it can be used.display.renderBarLabeltakes the same form for a bar's label (notattachedandowns): the node is disposed when the label is hidden (barLabel: 'none'), when its inputs change (the new label is made first, and a node it shares stays in the label), when the bar leaves the window and when the chart is destroyed, and is asked for again when the label comes back. Its width is not known: the node is kept between calls by whoever owns it and holds what was drawn last, so it is not measured, and'auto'placement puts it inside the bar (as for a label of no width); use'inside'or'outside'to choose.@ganttlane/reactis built on this: itsReactNodeslots are portals into such nodes.renderruns when its inputs change (another task, a new record, new
