npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@ganttlane/dom

v0.5.0

Published

Interactive gantt chart: vanilla DOM renderer and default CSS theme on top of @ganttlane/core.

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 until 1.0.

Coming from frappe-gantt, or from a modified fork of it? See the migration guide.

Contents

Install

# pick one
pnpm add @ganttlane/dom
npm install @ganttlane/dom
yarn add @ganttlane/dom
bun add @ganttlane/dom
  • @ganttlane/dom depends on @ganttlane/core, so this one install is enough to draw a chart. ESM only, with TypeScript declarations; TypeScript must resolve package exports (moduleResolution of bundler, node16 or nodenext).
  • Strict pnpm lets you import only what your own package.json lists. The types of the core that a signature of this package names, however deep (Task, Link, TaskRecord, ChangeSet, Issue, GanttCore, GanttEventMap, and the results of updateTask, addLink, removeLink and undo, the payloads of the events, the option groups, the before-hooks, …) are re-exported by this package, types only, so import type { Task, ChangeSet, LinkCreateResult } from '@ganttlane/dom' works. Runtime values of the core are not: add @ganttlane/core (the same version) to import patchTasks (controlled data), createGanttCore (a core of your own for mountRenderer) 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 (noUncheckedSideEffectImports is 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.js is minified (short issue messages) and sets one global, Ganttlane, with everything this package exports plus createTaskList and fromFrappeGanttOptions. It needs nothing else: the core, @internationalized/date and valibot are inside. https://cdn.jsdelivr.net/npm/@ganttlane/dom and unpkg serve it when you give no file.
  • dist/ganttlane.global.js is 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.css is styles.css minified (3.6 kB gzipped).
  • dist/THIRD-PARTY-LICENSES.txt holds the licences of the code inside the scripts.
  • The files are not in exports: a bundler never picks them up, and import '@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"> from https://cdn.jsdelivr.net/npm/@ganttlane/dom@VERSION/+esm (or https://esm.sh/@ganttlane/dom@VERSION).
  • For a page that must not change, name the exact version and add integrity="sha384-…" and crossorigin="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 one div.gantt to it and removes it on destroy().
  • The container is an element. createGantt throws a TypeError when container is 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 createGantt returns. The chart reports its first range and scroll position while it mounts, so onRangeChange and onScroll are called inside createGantt, and onIssues is when the first options or data have issues. A handler that reads the gantt variable finds it unset: see Handlers that run during creation.
  • It needs a DOM. createGantt measures 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/react renders its container on a server and draws the chart after hydration.
  • Open on your plan. scroll.initial is '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, a name, a start and an end (or a duration); a link joins a source to a target with a type (FS, SS, FF, SF) and a lag. Tasks nest through parentId, and a child can be pinned to its parent's edges. The chart never changes your objects: the computed form of a task is a TaskRecord (gantt.getTask(id), or the argument of an event) with Dates, lengths, working days, progress, issues and conflicts.
  • 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 with Z or an offset are rejected. A Date is 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 by scheduling.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 one ChangeSet with every task touched, and its conflicts (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 onTasksChange tells you. With dataMode: 'controlled' you own them: every committed change is reported, shown as a draft, and applied when you pass the new tasks back with setTasks; patchTasks(tasks, changeSet, gantt.core.getSnapshot().state.derived.model) from @ganttlane/core writes a change set into your own tasks in their own shape (a short example has the whole loop). With React the tasks / defaultTasks props make the choice, see @ganttlane/react.
  • Edit sessions and history. gantt.edit.begin(), commit() and rollback() are the "Edit, Save, Cancel" flow. A session does not hold changes back: they are applied as they happen (getTask() shows them, tasksChange fires). begin() remembers the data, edit.getChanges() is the net difference from it, commit() closes the session and returns that difference, and rollback() puts the remembered data back. Undo and redo need history: true: then undo() and redo() 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, …, and createGantt itself for onIssues) rethrows what the handlers threw, once the call is done and the change has happened: one error as it is, several as an AggregateError. A plain method throws synchronously; the async ones (updateTask, addLink, removeLink) reject their promise, so await gantt.updateTask(…) inside a try sees the error of your onTasksChange.
  • 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 unhandledrejection listener).

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, zoomTo and the scrollTo* 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). scrollToTask scrolls both axes and expands the collapsed parents of a hidden row unless expand: false. Its align (sideways) and alignY (vertically) also take 'nearest', the least scroll that shows it and none when it is in view, and false, which leaves that axis alone. scrollTo({ left, top, behavior }) goes to a position on either axis or both: top is in body pixels (the first row is at 0), and both are clamped to the content. A scrollTo* made before the chart has a width is not lost: it replaces scroll.initial when the chart gets one.
  • For what stands beside the chart. getRows() is the visible rows in order, each with id, index, name, depth, hasChildren, isExpanded, rootId, the stripes, setSize, position and record; 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) and getAppearance() its theme, height, rowStripes, rowHover and multiSelect. setHoveredRow(id) lights a row from outside the chart (snapshot.hoveredRow), and focusTask(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 (start alone moves the task, end alone moves its end edge, both set both) and runs onBeforeTaskChange with reason: 'api'. Values are exact (no snapping to the view mode and no rolling onto working days), and a value off the granularity follows scheduling.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', with issues); a before-hook that answers false makes it 'cancelled' with reason: 'hook', one that throws, rejects or answers something else with reason: 'error'; while a controlled change waits for your setTasks the chart is busy and the answer is 'busy'. After destroy() it resolves { status: 'cancelled', reason: 'disposed' }.
  • addLink and removeLink check the link first (shape, type, lag), then that both tasks exist, that it is not a duplicate and that it closes no cycle, and run onBeforeLinkCreate for a new one. A refusal is { status: 'rejected', reason } with reason 'invalid', 'unknown-task', 'duplicate', 'cycle' or, for removeLink with an id that is no link, 'unknown-link'. Two more answers: { status: 'cancelled', reason }, where reason is 'hook' (onBeforeLinkCreate answered false), 'error' (it threw, rejected or answered something else than a boolean) or 'disposed' (the chart was destroyed, the only way removeLink is cancelled), and { status: 'busy' } while another change waits for a hook or, with controlled data, for your setTasks.
  • editing does not apply to calls from code. editing: false turns 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, addLink and removeLink still commit. A locked task is the one updateTask leaves alone.
  • setTasks(tasks) and setLinks(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, no tasksChange and no history entry. With uncontrolled data they clear the undo history (its entries describe data that is gone); with controlled data setTasks is your answer to a change, which ends its draft (see dataMode). Like updateTask, they ignore editing.
  • setOptions(patch) changes the chart without recreating it. A key left out keeps its value, one set to undefined goes 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.height and the display options to the renderer's own setters. Swapping an on<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 groups tooltip, linkMenu and highlightTooltip, editing and history as objects, viewModes and 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 an invalid-option issue, since tasks has no default (the chart then shows no tasks; pass tasks: [] 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) and selectLink(id) an arrow; clicking an arrow selects it too. Clicking a bar emits taskClick and selects no task unless selection.onClick is on (with selection.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, unless selection.clearOnEmptyClick is on).
  • Events are the same whether you give an on<Event> option or call gantt.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 open

The 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 (and linkMenu.render) return a string (inserted as text, never parsed), a Node (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 call onSelect(context) and close the popover). null, '', whitespace, {}, a title of whitespace and markup that is only whitespace in elements ({ html: '<div> </div>' }) show nothing. Without render, 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 Node that someone else keeps alive (a framework's portal host, a widget that has to be torn down) is returned as { node, dispose } (DisposableNode): node is shown as any node is, and dispose() 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 inside node: 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 DisposableNode that is no text but whitespace and no element that draws without text (img, svg, canvas, video, iframe, hr, progress, meter and the controls), the rule { html } has; showsNothing(element) of @ganttlane/dom/dom-utils is that rule. A plain Node is shown as it is, however empty it looks. The node should have its content when it is returned, or draw it in attached; 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 by trigger: 'click'), so that what is in it can be used. display.renderBarLabel takes the same form for a bar's label (not attached and owns): 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/react is built on this: its ReactNode slots are portals into such nodes.

  • render runs when its inputs change (another task, a new record, new