A free, full-featured, open-source Gantt chart for every framework — Angular, React, Vue, Svelte, or plain JavaScript. Interactive editing (drag & resize with mouse, touch and keyboard, cells, links, progress, undo / redo), dependencies, critical path, baselines, virtual scrolling, export to SVG, PNG and CSV, paginated print, theming and i18n — accessible (WCAG 2.2 AA, forced colors), MIT licensed, zero runtime dependencies.
| Framework | Package | Install | Docs |
|---|---|---|---|
| Angular 22+ | @gantryjs/angular |
npm i @gantryjs/angular |
below |
| React 18 / 19 | @gantryjs/react |
npm i @gantryjs/react |
packages/react |
| Vue 3.5+ | @gantryjs/vue |
npm i @gantryjs/vue |
packages/vue |
| Svelte 5 | @gantryjs/svelte |
npm i @gantryjs/svelte |
packages/svelte |
| Vanilla JS / any | @gantryjs/vanilla |
npm i @gantryjs/vanilla |
packages/vanilla |
| Framework-free logic | @gantryjs/core |
npm i @gantryjs/core |
packages/core |
@gantryjs/core types · timeline math · tree helpers · critical path (no DOM, no framework)
├── @gantryjs/angular native Angular components (signals, zoneless)
└── @gantryjs/vanilla <gantry-element> Web Component
├── @gantryjs/react
├── @gantryjs/vue
└── @gantryjs/svelte
// React
import { Gantry, ViewMode } from '@gantryjs/react';
<Gantry tasks={tasks} options={{ viewMode: ViewMode.Week }} onTaskDragged={e => save(e)} />;<!-- Vue — import { Gantry, ViewMode } from '@gantryjs/vue' -->
<Gantry :tasks="tasks" :options="{ viewMode: ViewMode.Week }" @task-dragged="save" /><!-- Svelte — import { Gantry, ViewMode } from '@gantryjs/svelte' -->
<Gantry {tasks} options={{ viewMode: ViewMode.Week }} onTaskDragged={save} /><!-- Plain HTML / any framework -->
<gantry-element id="gantt"></gantry-element>
<script type="module">
import '@gantryjs/vanilla';
const gantt = document.getElementById('gantt');
gantt.tasks = [{ id: '1', name: 'Design', start: new Date('2026-01-05'), end: new Date('2026-01-20'), progress: 40 }];
gantt.addEventListener('task-dragged', e => console.log(e.detail));
</script>Every binding (Angular [options], React / Vue / Svelte options, <gantry-element>.options)
takes the same GanttOptions object, and both renderers draw from the same @gantryjs/core
render model — an option behaves identically everywhere. Try them all live in the
playground — the site's /playground (pnpm start) or the Web Component one
(pnpm dev:vanilla).
Each binding documents the rest of its API in its own README:
- Web Component
<gantry-element>(the React, Vue and Svelte wrappers forward to it): properties, attributes, events, methods, slots, keyboard and focus, CSS custom properties, parts, custom states and Content Security Policy. The package ships a Custom Elements Manifest (custom-elements.json) for IDEs, Storybook and linters. - Angular
<gantry>: Inputs & Outputs, Public API, content slots and Theming.
Keyboard, touch, zoom, screen-reader and forced-colors behaviour is the same in every binding: see Keyboard and focus, Touch and pointer, Zoom, Screen readers and Forced colors below.
On this page: Timeline · Week numbering · Non-working time · Working calendar · Resources · Bars · Task styles · Rollup · Panel and table · Responsive and density · Toolbar · Tooltip · States · Synchronised charts · Editing · Export and print · Dates and locales · Theming · Styling hooks · CSP · SSR · Examples.
import { ViewMode, WEEK_FORMATS } from '@gantryjs/core';
options = {
viewMode: ViewMode.Week, // Hour · Day · Week · Month · Quarter · Year, or a custom level name
weekStart: 1, // 1 = Monday (ISO weeks), 0 = Sunday
scales: [
// header tiers, top → bottom (any number)
{ unit: 'month', format: 'MMMM YYYY' },
{ unit: 'week', format: WEEK_FORMATS.rangeMonth }, // "4–11 Mar"
],
zoomLevels: [
// replace the built-in presets
{ name: 'Sprint', cellWidth: 120, scales: [{ unit: 'month' }, { unit: 'week', step: 2, format: '[W]W' }] },
],
gridLines: {
major: { color: '#94a3b8', width: 1.5, dash: '4 3' }, // period delimiters (header + body)
minor: { width: 1 }, // between cells; `false` hides
horizontal: false, // row separators
majorUnit: 'month', // default: the tier above the bottom one
},
headerHeight: 32,
subHeaderHeight: 28,
fitToWidth: true, // the timeline always fills the viewport
initialScroll: 'auto', // 'auto' · 'today' · 'start' · a Date · false
};unit:hour·day·week·month·quarter·year;stepgroups cells (6 hours, 2 weeks…).format: a dayjs-compatible format string ('MMM YYYY','[W]W','ddd D', see date formats) or(start, end, ctx) => string.- Week presets:
WEEK_FORMATS.number(W52),.label(Week 52),.range(4–11),.rangeMonth(4–11 Mar),.numberRange(W52 · 4–11). className: (start, end) => stringadds your own class to header cells.- Upper-tier labels stay pinned to the left while scrolling (
align: 'start'). A cell cut by the edge of the range shows no label when it is narrower than its label + 12 px, so no clipped slivers. initialScrollpicks where the first render with data scrolls:'auto'(default) centres today when it is near the tasks, otherwise shows the earliest task;'start'the earliest task;'today'or aDatecentred;falsestays at the timeline start. Changing it re-applies it. Zooming from the toolbar, the keys orzoomIn()/zoomOut()keeps the date at the centre of the viewport in place, zoom gestures the date under the pointer (see Zoom), andprefers-reduced-motionturns smooth scrolling into an instant jump.
options = {
weekNumbering: 'us', // 'iso' · 'us' · 'locale'
weekStart: 6, // 1 = Monday, 0 = Sunday, 6 = Saturday; overrides the rule's first day
};'iso': weeks start on Monday and week 1 holds 4 January (the default).'us': weeks start on Sunday and week 1 is the week that holds 1 January.'locale': the rule ofoptions.locale(Intl.Locale#getWeekInfo(), else ISO).- Unset,
weekNumberingis'us'whenweekStartis0, otherwise'iso'. Charts that set neither keep their ISO weeks. - Under the US rule the days of the week that holds 1 January are week 1 of the new year, even
when they fall in December: Sunday 27 December 2026 reads
W1, notW53. - Week cells start on
weekStart, and theW/WWtokens andWEEK_FORMATSfollow the rule. Core exportsweekOfYear(date, rule),weekYear(date, rule)and the rulesISO_WEEK,US_WEEK.
options = {
nonWorkingTime: {
weekends: true, // or weekday numbers, e.g. [5, 6]
holidays: [new Date(2026, 11, 25), { start: d1, end: d2, label: 'Shutdown', color: '#fee2e2' }],
workingHours: { start: 9, end: 18 }, // or per weekday: { 1: [{ start: 9, end: 13 }, { start: 14, end: 18 }] }
intensiveHours: [{ start: 10, end: 12, label: 'Focus', color: 'rgba(245,158,11,.2)' }],
style: 'shade', // 'shade' (tinted by kind) | 'hatch' (+ stripes) | 'disabled' (one neutral grey) | 'none' (no band)
showInHeader: true,
minPxPerDay: 12, // hide day shading in dense zoom levels
},
highlightTime: (start, unit) => (isRelease(start) ? { color: '#dcfce7', label: 'Release' } : null),
};Shading is visual only until you turn on the working calendar, which counts durations in the
same working time; without it durations stay in calendar days. Colours default to the theme
(weekendBg, holidayBg, offHoursBg, intensiveBg). nonWorkingTime: false turns it off.
style: 'none' keeps the definition but paints no weekend, holiday or off-hours band (intensive hours and
highlightTime still paint), so a chart can count working time without the stripes.
Durations count calendar days unless you turn on calendar. Then the same weekends, holidays and working hours
that nonWorkingTime shades also drive the arithmetic: durations, drags, cells, the critical path and its float
count working time, so a bar dragged across a weekend keeps its working days and stretches over the days off.
options = {
calendar: true, // = { durations: 'working', unit: 'day', skipNonWorking: true, hoursPerDay: 8 }
nonWorkingTime: { holidays: [new Date(2026, 11, 25)] }, // Saturday and Sunday are off unless you say otherwise
panel: { columns: ['name', 'start', 'end', 'duration'] },
};| Option | Default | Effect |
|---|---|---|
calendar |
off | true is {}. Off (unset or false) is today's behaviour exactly: calendar days, nothing snapped |
calendar.durations |
'working' |
What durations count: 'working' time, or 'calendar' days (texts as without a calendar; skipNonWorking still applies) |
calendar.unit |
'day' |
'day': a working day is a whole day. 'hour': working time is the hours of nonWorkingTime.workingHours, 09:00–17:00 when you set none |
calendar.skipNonWorking |
true |
Edits never start or end a task on non-working time: starts move forward, ends back |
calendar.hoursPerDay |
8 |
With unit: 'hour': the working hours that make 1d in texts and typed durations (above 0, up to 24). It doesn't change the hours themselves |
- One definition. The calendar is built from the resolved
nonWorkingTime— the weekends (Saturday and Sunday by default), holidays andworkingHoursthe chart shades — so shading and arithmetic can't disagree.nonWorkingTime: falsewith a calendar means no non-working time at all (every day, and withunit: 'hour'every hour). A week with no working time (every weekday a weekend, orunit: 'hour'with no hours) turns the calendar off. - Hours. With
unit: 'hour'a working day is itsworkingHoursranges (one range, a list, or a per-weekday map; a weekday missing from the map has no hours; weekends and holidays always win), counted on the wall clock. The default 09:00–17:00 is the hours that are shaded too.
A "measured day" is a working day (with unit: 'hour': working hours ÷ hoursPerDay). With
durations: 'working' they are what these show and use:
- the Duration column (text and sort), the tooltip's duration, the drag readout, and the variance column and tooltip row (signed, from the planned to the actual end);
- the duration cell (its text, what you type and the end it writes), the start cell (it keeps the working length) and predecessor lags;
rollup.progress: 'duration'weights, and the CSVdurationandvariancedata values;- the critical path: durations,
GanttDependency.lagand float.
Texts read "1 day", "5 days" (never weeks: a working week isn't 7 days) and, in an hour calendar, "6 hours" under
one day (labels.durationHours); the tooltip's float reads "2d" or "5h" (labels.ttFloatDays, labels.ttFloatHours).
A typed duration or lag takes d (a measured day), w (the working week: the weekdays with working time, 5 by
default, 7 with durations: 'calendar') and h (an hour of the working day: 1 / hoursPerDay with unit: 'hour',
else 1 / 24). The cell shows it back as 5d, 1.5d or 12h (in an hour calendar whole days 2d, else hours 20h).
Not changed: the bars (a bar covers the days off it spans), the arrow lag labels (+2d), the event amounts
(daysMoved is the real calendar shift of the start) and the announcements ("Design moved 3 days later").
Only gestures and cells are snapped (skipNonWorking), never your data: tasks, updateTask(), appendTasks(),
the API, undo and redo draw what you give, a task that starts on a holiday keeps it until it is edited, and
validation never rejects a non-working date (do that in editing.validate, whose context has calendar: a
WorkingCalendar or null).
| Edit | What happens |
|---|---|
| Drag a bar | The start moves by the pointer's snap units and snaps forward to where working time begins; the end keeps the working length (the bar widens over days off) |
| Resize an edge | The edge moves by the snap units; a start snaps forward, an end back to right after working time. With working durations the task never gets shorter than one snap unit of working time (on a day scale of an hour calendar: the working hours of the edges' weekdays, up to hoursPerDay; durations: 'calendar': only the usual minimum width) |
| Move mode (← →, Shift / Alt) | Each key press moves, or resizes, by one unit of working time (a working day; an hour on hour scales): no dead key over a weekend |
| Start cell | The start snaps forward; the end follows the working length |
| End cell | The end snaps back; an end before the start is an error, as before |
| Duration cell | Sets the end from the start, not snapped: 3d reads 3d |
With durations: 'calendar' a moved bar keeps its calendar length, and with skipNonWorking its end snaps back so
it never ends on a day off (Monday–Tuesday moved to Friday ends on Saturday 00:00). With durations: 'calendar'
and skipNonWorking: false nothing is snapped, as without a calendar. A move that keeps the working length is one task-dragged (no task-resized) and one undo step.
Dates. A bar is measured on what it draws: date-only values are local days, and with the default
endDateMode: 'exclusive' a Monday–Friday task ends on Saturday 00:00 ('inclusive': on Friday). Edited dates keep
the task's own form: a date-only value stays date-only and an inclusive end stays inclusive. In an hour calendar
the results carry times, so a date-only task edited there gains its working hours.
criticalPath: true computes the path in working time, so its chain can differ from the calendar-day one (the
gallery's Critical path in working time case flips it with its Durations control). The tooltip shows the float of the tasks off
the path. Headless, pass the resolved config:
import { computeCriticalPath, createWorkingCalendar, formatDuration, resolveConfig } from '@gantryjs/core';
const options = { calendar: true, nonWorkingTime: { holidays: [new Date(2026, 9, 21)] } }; // Wed 21 Oct 2026
const calendar = createWorkingCalendar(options)!; // null when the week has no working time
const friday = new Date(2026, 9, 16);
calendar.addWorking(friday, 1); // Sat 17 Oct 00:00: right after Friday's working time
calendar.addWorking(friday, 2); // Tue 20 Oct 00:00: Friday, then Monday
calendar.snapStart(new Date(2026, 9, 17)); // Mon 19 Oct 00:00: a start moves forward
calendar.snapEnd(new Date(2026, 9, 19)); // Sat 17 Oct 00:00: an end moves back
calendar.workingDuration(new Date(2026, 9, 19), new Date(2026, 9, 24)); // 4: Wednesday is a holiday
const config = resolveConfig(options); // what the charts read
formatDuration({ start: friday, end: new Date(2026, 9, 20) }, config); // '2 days'
const { criticalIds, floatMap } = computeCriticalPath(tasks, config); // working daysWorkingCalendar also has isWorkingDay(), isWorking(), toWorking(), fromWorkingStart() and fromWorkingEnd().
The measuring helpers formatDuration(), formatFloat(), taskDuration(), spanDuration(), measureBetween(),
measureVariance() and addDuration(), and the snaps snapTaskStart(), snapTaskEnd() and followTaskEnd(),
take the same config; DEFAULT_WORKING_HOURS and DEFAULT_HOURS_PER_DAY are the defaults.
- Hours count on the wall clock, so 09:00–17:00 is 8 hours on a daylight-saving day too. Where an hour calendar's hours include the hour that repeats when the clocks go back (a night shift), the working-time count steps back there.
- A timed task whose time is exactly midnight UTC in the viewer's zone is read as a date-only day (the
normalizeDateOnlyrule): setnormalizeDateOnly: falsefor timed tasks in an hour calendar. hoursPerDayis what1dmeans, even when a shift is shorter or longer.- The event amounts (
daysMoved,daysDelta) and the announcements stay calendar shifts, andlabelsgain the requireddurationHoursandttFloatHours(only a fullGanttLabelsobject must list them:options.labelsis partial).
Give the chart the people and teams your tasks are assigned to (the resources property, like tasks and
annotations; the resources input in Angular) and it can answer who is busy when: rows: 'resources' swaps the
one-row-per-task list for one row per resource, each drawing its daily load against its capacity, with the load
above the capacity hatched.
resources = [
{ id: 'ana', name: 'Ana' }, // capacity 100 %: one full-time person
{ id: 'bo', name: 'Bo', capacity: 50 }, // half-time
{ id: 'cy', name: 'Cy', group: 'Engineering' }, // the same group = one team row
{ id: 'di', name: 'Di', group: 'Engineering', color: '#0E7490' }, // the colour of Di's load bars
];
tasks = [
{ id: 'design', name: 'Design', start: new Date(2026, 9, 12), end: new Date(2026, 9, 16), resources: ['ana'] },
{
id: 'build',
name: 'Build',
start: new Date(2026, 9, 14),
end: new Date(2026, 9, 22),
resources: ['cy', { resourceId: 'ana', units: 100 }, { resourceId: 'bo', units: 50 }], // 50 %: half a person
},
];
options = {
rows: 'resources', // 'tasks' (default) | 'resources'
resourceView: { unassigned: true, overallocation: true },
toolbar: { rowsToggle: true }, // an icon button that flips the rows
calendar: true, // optional: only working days carry load
};| Field | Default | Meaning |
|---|---|---|
GanttResource.id |
— | Unique (the first of a repeated id wins; a blank id is ignored) |
GanttResource.name |
— | What the row, the tooltip and the Resources cell call it |
GanttResource.capacity |
100 |
Percent of one person's time available per working day: 50 half-time, 200 a team of two. Not finite or not above 0 reads as 100 |
GanttResource.group |
— | Resources with the same group sit under one collapsible team row |
GanttResource.color |
theme | Colour of its load bars (any CSS colour); default theme.barColor |
GanttTask.resources |
— | A resource id, or { resourceId, units }; units is the percent of a full-time person the task needs per working day (default 100) |
An id that names no resource is ignored; a task whose assignments are all unknown (or empty) is unassigned; a repeated resource keeps its first entry.
| Option | Default | Effect |
|---|---|---|
rows |
'tasks' |
'resources' shows one row per resource and team. Like view it is the initial value: the toggle and setState({ rows }) change it at run time |
resourceView.unassigned |
true |
An "Unassigned" row (labels.resUnassigned) for the tasks without a known resource; it exists only while there is one |
resourceView.overallocation |
true |
The load above the capacity in theme.overallocatedColor, hatched. false draws plain load |
resourceView.rowHeight |
1.5 × the row height | Height in px of a resource row (54 at the normal density; 28 at least). It replaces rowHeight while the rows are resources |
toolbar.rowsToggle |
false |
An aria-pressed icon button (named labels.showResources, titled labels.showTasks while pressed) after the task list toggle, shown only while resources is not empty. It never moves into the More menu |
getState() and state-change always carry rows, and stateKey saves it. Unset rows and no resources change
nothing: the chart draws and exports exactly as before.
Mon Tue Wed Thu Fri
Ana 100 % ▒▒▒▒▒ ▒▒▒▒▒ ▒ load above the capacity (hatched)
┄┄┄┄┄┄┄┄┄┄┄┄┄┄ ┄┄┄┄┄ ┄┄┄┄┄ ┄┄┄┄┄ ┄┄┄┄┄ ┄┄┄┄┄ ┄ the capacity line
█████ █████ █████ █████ █████ █ the load, up to the line
- Load. Per resource and local calendar day: the
unitsof every task assigned to it that day. A task counts on each day its bar touches, in full (a task ending at 14:00 counts that whole day; with the defaultendDateMode: 'exclusive'a date-only end is the first day after the task). Parent tasks (a task with children, ortype: 'group') and milestones carry nothing: a parent would double its children. With a working calendar only working days count (weekends and holidays carry zero); without one every day does. - Over-allocation. A day is over when its load is above the capacity. The over part is drawn in
theme.overallocatedColorwith a hatch, so colour is never the only cue, and the capacity line is dashed. - Teams. The resources of one
groupsit under a team row whose load and capacity add up its members' (a team can be within its capacity while a member is over). Rows keep the order ofresources, a team where its first member is (not sorted by start date), and Unassigned comes last; it counts 100 % per unassigned task per day, has no capacity line and is never over. - Scale. One vertical scale per chart, so every 100 % line is at the same height: at least 150 %, in steps of 25 %, fitting the highest load or capacity. Zoomed far out, days merge into buckets by their highest load: a spike is never averaged away. On an hour scale a day's load spans the whole day.
- Columns. The task list gets three built-in columns:
resources(the assignments as text,Ana, Bo 50%),capacityandload("Peak load": the highest day; a team shows its members' capacity). In the resource view the default columns arename,capacityandload, in the split panel and the table view alike (an explicitpanel.columns,table.columnsorcolumnswins; Start and End then show the resource's extent); the tasks view's defaults are unchanged, so add'resources'to itspanel.columns/table.columnsor callsetColumnVisible('resources', true). Sorting uses the numbers. The three columns are built-ins only once the chart hasresourcesor shows the resource view; before that an app's own column keyedresources,capacityorloadis a plain data column. An explicitcolumnsproperty withrows: 'resources'shows the app's columns on the resource rows too (their durations and statuses come from the rows' extents), so listcapacityandloadin it. - Tooltip. Hovering a resource row's timeline opens the tooltip for that day: the date,
labels.ttResLoad,ttResCapacity,ttResOver(only when over) and the tasks with their units (eight, thenlabels.ttResMore(n)). The mouse and pen open it; touch doesn't (the numbers are in the columns and the row names). - Not drawn. Resource rows have no bars, arrows, links, progress grip, baseline, critical path or rollup, and are
read-only (
editable: false,draggable: false).
Assignments are edited in the tasks view: with editing.cells the resources column is editable (the text
Ana, Bo 50%: resources by id or by name, case-insensitive and exact; , or ; between them; N% after a name for the
units; empty text unassigns). A commit is one 'cell' change through the pipeline (so
before-change can veto it, Undo reverts it and task-updated lists resources in fields); updateTask() takes
resources like any field. An unknown name gives labels.errorUnknownResource(ref), a name shared by
several resources errorAmbiguousResource(ref). capacity and load are never editable.
Show the plan and the workload together with two charts that share a syncGroup: the top one
in the tasks view with a Resources column and editing, the bottom one with rows: 'resources', both fed the same
tasks and resources (keep the tasks you get back in tasks-change), so the load rows follow every edit. The
gallery's Resource panel under the plan case does exactly that.
The resource rows are ordinary task-list rows: the same arrows, Home / End and expand / collapse keys (team rows are
aria-expanded), and the toggle is a toolbar button (Enter / Space). A resource row is named
labels.describeResource(name, capacity, peak, overDays) — "Ana, capacity 100%, peak load 200%, 2 days over capacity",
and Unassigned "Unassigned, Load 100%" —; the histogram itself is aria-hidden, so the numbers are in the row name and
the Capacity / Peak load columns. There is no keyboard day-by-day breakdown: for one, show the tasks view with the
Resources column. In forced-colors mode the load is CanvasText, the part over the capacity Highlight with the hatch,
and the capacity line GrayText.
Selection and row-click / rowClick in the resource view return the synthetic row: its task.id is
res:<id>, team:<group> or unassigned, task.barType is 'resource' and task.data.resource is
{ kind, id, name, capacity, peak, peakDay, overDays, tasks } (read it with resourceInfoOf(task)). They are not
your tasks.
SVG, PNG and print draw the resource view as shown (every row, the load, the hatch as a second <pattern>, the
capacity line, no bars or arrows); the CSV writes capacity and load (the peak) as percent numbers on resource rows
and resources (ids with units, ana, bo 50%) on task rows. columns: 'all' lists the three columns only when the
chart has a use for them (capacity and load in the resource view, resources when there are resources), so a
chart without resources exports exactly as before. Headless, createExportSource takes resources and rows.
import {
computeResourceUtilization,
createExportSource,
dateReadingOf,
exportChartSvg,
indexResources,
resolveConfig,
} from '@gantryjs/core';
const options = { calendar: true }; // optional: weekends carry no load
const index = indexResources(resources);
const utilization = computeResourceUtilization(tasks, index, dateReadingOf(resolveConfig(options)), {
unassigned: true,
});
const ana = utilization.rows.get('res:ana')!;
ana.peak; // 200 (percent of a person)
ana.overDays; // 2
ana.loads; // a Float64Array: the percent of each day, from the epoch day `ana.firstDay`
const svg = exportChartSvg(createExportSource({ tasks, resources, options: { ...options, rows: 'resources' } }));loadAt() and contributorsAt() read one day; projectResourceRows() / resourceViewTasks() build the synthetic rows;
computeLoadVisuals() and hitTestLoad() give the histogram paths and the row and day under a point;
buildResourceTooltip() the tooltip; parseAssignments() / formatAssignments() the cell text; resourceInfoOf(),
toggleRowsMode() and LOAD_STYLE (the opacities and dash of the drawing) complete the set.
- Day granularity. A task counts fully on every day it touches, its first and last included: a one-hour task is a whole unit on its day.
- No lanes. Resource rows are one fixed height and hold no task bars; there is no vertical drag to reassign a task (do it in the tasks view).
- No touch tooltip and no keyboard day-by-day breakdown (see above).
- Synthetic rows. Selection and row events in the resource view carry the synthetic row, not your tasks.
- The
labelsgain required members (colResources,colCapacity,colLoad,resUnassigned,showResources,showTasks,ttResLoad,ttResCapacity,ttResOver,ttResMore,describeResource,errorUnknownResource,errorAmbiguousResource): only a fullGanttLabelsobject must list them,options.labelsis partial.
options = {
rowHeight: 40,
bar: {
height: '60%', // px or % of the row
radius: 6,
minWidth: 4,
progress: { show: true, color: '#1e3a8a', opacity: 0.3 },
label: { position: 'auto', template: t => `${t.name} · ${t.progress ?? 0}%` }, // inside | left | right | auto | none
handles: { hitSize: 24 }, // width of each resize target in px (see Touch and pointer)
},
milestone: { size: 18, shape: 'diamond' }, // diamond | circle | square
groupBar: { style: 'summary', height: '40%' }, // summary (bracket) | bar
arrows: { width: 1.5, headSize: 5, radius: 6, offset: 12, color: '#64748b', routing: 'orthogonal' }, // or 'straight'
indentSize: 16,
};arrows.routing is 'orthogonal' (default, right angles with rounded bends) or 'straight': one
line from the exit edge to the entry edge, its head along the line and its lag label beside the
midpoint.
Per task: style (see Task styles), draggable and editable (see Editing); the flat barColor,
progressColor, textColor, rowColor and className still work but are deprecated aliases
of style.color / progressColor / textColor / rowColor / className.
For full control replace the bar renderer (renderBar in the Web Component / bindings,
provideGanttBar in Angular) — custom renderers receive the computed geometry in ctx.visual
and its colours in ctx.visual.paint (see Theming). The chart gives every bar,
custom ones included, its role, name and roving tabindex (see Screen readers),
hit-tests it by its visual box, and draws its focus ring and resize grips in an overlay
(g.gantt-bar-overlay, from ctx.visual.focusRect and ctx.visual.handles.{left,right}.grip),
so a renderer draws only the bar. ctx.visual.handles is { left, right } (each a hit rect
and a grip) or null when the bar can't be resized.
options = {
styles: {
task: { radius: 3 }, // by bar kind: task · group · milestone
parent: { color: '#475569', textColor: '#fff' }, // every row with children
levels: [{ height: '70%' }, null, { color: '#94a3b8' }], // by depth: 0 = root rows
rule: (row, { critical, status }) => (status === 'overdue' && !critical ? { color: '#dc2626' } : null),
},
};
tasks = [
{ id: '7', name: 'Launch', start, end, style: { color: '#7c3aed', rowColor: '#faf5ff', className: 'launch' } },
];A style has color, progressColor, textColor, rowColor, className, height, radius and
label ('inside' | 'left' | 'right' | 'auto' | 'none'), all optional. Each field is resolved on its
own, later layers winning:
| Order | Layer |
|---|---|
| 1 | the theme and the bar / milestone / groupBar options |
| 2 | styles.task · styles.group · styles.milestone (by bar kind) |
| 3 | styles.parent (rows with children) |
| 4 | styles.levels[depth] |
| 5 | the rolled-up colour (rollup.color) |
| 6 | styles.rule(row, { rollup, critical, status }), computed once per row |
| 7 | the deprecated flat fields (task.barColor, …) |
| 8 | task.style (always wins; task.style beats the flat fields) |
className accumulates: every layer's classes apply, each once. The critical path still paints
its own fill. Custom bars get the result in ctx.style (GanttBarContext), and the built-in
bars, the row background in both panes, the task-list icon and the tooltip's progress fill read
the same style.
options = {
rollup: true, // = { progress: 'duration', dates: true, color: false, segments: false }
// rollup: { progress: 'count', dates: false, color: 'status', segments: true },
groupBar: { collapsed: true }, // every parent starts collapsed
};progress:'duration'(default) is the mean of a parent's direct children weighted by their duration (milestones weigh 0),'count'the plain mean,falsekeeps the parent's own.dates(defaulttrue): the parent spans the earliest child start to the latest child end; its bar can't be dragged.color:'status'paints the children's summary status, a function(children, parent) => colour | null,false(default) keeps the styles.segments(defaultfalse): a collapsed parent draws its children inside its bar, each in its own colour (rect.gantt-bar-segment).- Rolled-up values reach the task list, sorting, bars, tooltips and accessible names; events
(
rowClick,getSelectedTasks()) keep the raw tasks. groupBar.collapsedcollapses every parent the chart has not seen yet (an explicittask.collapsed: falsekeeps one open). Withrollupit is the "only parents" view.
options = {
panel: {
width: '30%',
minWidth: 160,
maxWidth: '70%',
resizable: true,
collapsible: true,
collapsed: false,
columns: ['name', 'start', 'end', 'progress'],
}, // keys shown next to the timeline
table: { columns: ['name', 'type', 'status', 'start', 'end', 'duration', 'progress', 'predecessors'] },
view: 'split', // 'split' | 'table' (every useful field, animated) | 'chart'
toolbar: { zoom: true, fit: true, viewModes: ['Day', 'Week', 'Month'], today: true, viewToggle: true }, // or false
stateKey: 'project-42', // remember panel width, view, zoom, column widths, collapsed rows, sort
// (saved as versioned JSON in localStorage, validated on load; a snapshot from a newer version is ignored)
};
columns = [
{ key: 'name', label: 'Task', width: 220, flex: true }, // flex: fills spare panel width
{ key: 'owner', label: 'Owner', width: 120, align: 'center' },
{ key: 'budget', label: 'Budget', width: 100, tableOnly: true }, // only in the table view
];- Drag the splitter to resize (also past the last column); the chevron collapses the list and restores the last width of that instance. Columns wider than the panel scroll horizontally.
- Column widths are resizable from the header edge (
resizable: falseto lock one). - The split panel and the table view show different columns — see Columns: split panel vs table view.
state-change/(stateChange)/onStateChangereports every layout change;getState()/setState()save and restore it from your own backend. The state always carriesrows('tasks'or'resources', see Resources).
options = {
density: 'compact', // 'compact' · 'normal' (default) · 'comfortable'
responsive: { breakpoint: 640, view: 'chart' }, // the default; `false` turns it off
};density |
Row height | Header tiers | Toolbar controls |
|---|---|---|---|
compact |
28 | 28 / 24 | 28 px |
normal |
36 | 32 / 28 | 32 px |
comfortable |
44 | 36 / 32 | 36 px |
- An explicit
rowHeight,headerHeightorsubHeaderHeightwins over the density. - Below
responsive.breakpointpx of chart width (not the window) the chart showsresponsive.viewand goes back to the wide view when it widens. A view the user picks while narrow wins until the width crosses the breakpoint again. The forced view is never saved:getState().viewandstateChangekeep the wide view. - The chart carries
data-densityand, while narrow,data-narrow(see Read-only CSS variables).
options = {
toolbar: {
zoom: true,
fit: true,
today: true,
viewToggle: true,
panelToggle: false,
overflow: 'menu',
undo: true,
export: false,
rowsToggle: false,
}, // `false` hides the toolbar
legend: { position: 'bottom' }, // 'bottom' | 'toolbar' (`showLegend` turns the legend on)
markers: { lane: 'auto' }, // 'auto' | true | false
};
annotations = [{ x1: new Date(2026, 2, 2), label: { text: 'Code freeze' }, color: '#B91C1C' }];- Toolbar. Zoom out, zoom in and Fit are 16 px icon buttons, Today is an icon with its
label, and the zoom levels (
labels.viewModes) and the views are radio groups (named bylabels.ariaZoomLevels/labels.ariaViews); the views are icons namedlabels.viewSplit/viewTable/viewChart("Table and chart", "Table only", "Chart only"), also shown as theirtitle. Every control is at least 32 × 32 px.toolbar.fit(defaulttrue) shows the Fit button, which callsfitToScreen()(see Zoom). The toolbar is a single tab stop (see Keyboard and focus); a radio is checked by Space, Enter or a click, never by moving focus onto it. Angular's[ganttToolbar]content sits between Today and the view switch, outside that roving set. - Panel toggle and More menu.
toolbar.panelToggle(defaultfalse) adds a button first that shows / hides the task list (aria-pressed;labels.showPanel/hidePanel): hiding it goes to the chart view (from the table view too), showing it to the split view, astogglePanel().toolbar.overflowdecides what happens when the controls don't fit the measured width:'menu'(default) moves the Export menu, then the zoom levels, then the views, then Today into a More button (labels.more), a WAI-ARIA menu button (↓ / ↑, Home / End, Enter / Space, Esc, Tab and an outside press close it);'wrap'wraps them onto more lines. A toolbar that fits renders as it always did. - Export menu.
toolbar.export(defaultfalse) adds an Export icon button (labels.exportMenu) after the view switch. It opens a menu with one item perexport.formatsentry (labels.exportSvg"SVG image",exportPng"PNG image",exportCsv"CSV table",exportPrint"Print…"; no formats, no button): an item runsdownload(format)orprint()withtrigger: 'toolbar'. Same keys and focus rules as More (↓ opens on the first item, ↑ on the last; focus goes back to the button after an item), and one of the two menus is open at a time. It is the first group to move into More, where its formats become a section namedlabels.exportMenu(never a nested menu). Withtoolbar.exportoff the toolbar is unchanged. - Undo and Redo.
toolbar.undo(defaulttrue) adds two icon buttons after the panel toggle while the chart keeps an editing history (editingon, notcontrolled):labels.undo/labels.redo,aria-disabledwhile there is nothing to undo or redo, in the toolbar's arrow-key set and never moved into the More menu. - Rows toggle.
toolbar.rowsToggle(defaultfalse) adds an icon button after the panel toggle that flips the rows between one per task and one per resource (aria-pressed,labels.showResources; its title readslabels.showTaskswhile pressed). It exists only while theresourcesproperty is not empty, and never moves into the More menu. See Resources. - Legend.
showLegendturns it on. It lists the today marker (withshowToday), every annotation and, when those options are on, the critical path and the baseline; each entry has a shape as well as a colour.legend.position: 'bottom'(default) puts it in a strip under the task list and the timeline;'toolbar'at the end of the toolbar, falling back to the bottom when the toolbar is hidden. It is not shown in the table view. Its strings:labels.legend,today,annotation,legendCritical,legendBaseline. - Marker lane. An 18 px lane under the header tiers holds a tick for each annotation with
its label, and a Today chip (
labels.today) whileshowTodayis on, so labels never cover bars.markers.lane: 'auto'(default) shows it when at least one annotation has a label,truealways,falsenever; the table view has none. Lane labels use the theme text colour (--gantt-text, so they stay readable on the header), ticks the annotation colour (today:--gantt-today, its label--gantt-today-label). A label that doesn't fit before the next marker is cut short (itstitlehas the full text) and hidden under 24 px. With the lane off, annotation labels are drawn in the timeline as before, inlabel.color.
options = {
tooltip: {
trigger: 'auto', // 'auto' (hover, keyboard focus, tap) · 'hover' · 'focus'
showDelay: 320, // ms the pointer rests on a bar before it opens; focus opens it at once
hideDelay: 120, // ms it stays open after the pointer leaves the bar
render: ({ task, model }) => `${task.name}: ${model.rows.length} facts`, // a string or a DOM node
},
};-
tooltipEnabled(property / input / prop, defaulttrue) turns the tooltip on. It is one native popover per chart (div.gantt-tooltip[role=tooltip], Popover API), placed below its bar (above it when there is no room), centred on the pointer while hovering and on the bar otherwise, and kept inside the window. -
'auto'opens it on hover, when a bar gets keyboard focus, and on a tap (a second tap closes it). Once it shows, hovering another bar switches at once. The pointer can move onto the tooltip and rest there, Escape closes it (before it clears the selection), and so does a press elsewhere, a drag or keyboard move mode. While it shows, its bar hasaria-describedbypointing at it (WCAG 1.4.13). -
render(ctx)replaces the title and the rows (the box, its placement and timing stay the chart's): return a string (shown as text) or a DOM node, which filldiv.gantt-tooltip-custom;null,undefinedorfalsekeeps the built-in content, and so does arenderthat throws (the error is logged).ctxis{ model, task, row }:modelis what the built-in tooltip shows (title,rowsof{ label, value },progress,status,critical, …). The React, Vue and Svelte wrappers pass it to the Web Component as is: return a string or a DOM node, not a React element, a VNode or a snippet. -
In the resource view the tooltip belongs to a day of a resource row (mouse and pen, not touch): the date, the load, the capacity,
Over capacitywhen over, and the tasks that make it up. Neitherrender(ctx)nor an AngularganttTooltiptemplate applies to it (there is no task to give them). -
Angular: an
<ng-template ganttTooltip>inside<gantry>(importGanttTooltipTemplateDirective) takes precedence overrender. Its context isGanttTooltipTemplateContext($implicit/model,task,row):<gantry [series]="tasks"> <ng-template ganttTooltip let-model let-task="task"> <strong>{{ task.name }}</strong> · {{ model.rows.length }} facts </ng-template> </gantry>
| Binding | Loading / error | Retry pressed | Empty-state content |
|---|---|---|---|
<gantry-element> |
el.loading = true · el.errorMessage = 'Couldn’t load the plan' (loading, error-message attributes) |
retry event |
a <div slot="empty">…</div> child |
Angular <gantry> |
[loading]="pending()" · [errorMessage]="error()" |
(retry) |
<div ganttEmpty>…</div> inside <gantry> |
| React | loading={pending} · errorMessage={error} |
onRetry |
a <div slot="empty"> child |
| Vue | :loading="pending" · :error-message="error" |
@retry |
a <div slot="empty"> in the default slot |
| Svelte | {loading} · {errorMessage} |
onRetry |
an empty snippet ({#snippet empty()}) |
The chart body shows one state at a time, by precedence:
- Error — a non-blank
errorMessage: a box over the body (role="alert") withlabels.errorTitle("Couldn’t show the chart"), your message and a Retry button, which firesretrywith{ source: 'error' }: load again, then clearerrorMessage. - Loading —
loading(or a data source fetching its first page) while there are no rows: placeholder rows (their shimmer stops underprefers-reduced-motion) withlabels.loadingas visually hidden text, and the host isaria-busy. Over rows already shown,loadingonly setsaria-busy. - Error of a data source — its first page failed after its automatic retries: the same
box with
labels.loadError; Retry firesretrywith{ source: 'dataSource' }and callsdataSource.retry(). A later page fails in the task-list footer instead (its Retry only callsdataSource.retry()). - Empty — no rows and nothing more to load: your empty-state content, else
labels.emptyState("No tasks to show"). Setloadingwhile you fetch, so the first paint is the skeleton rather than the empty state.
RetryEvent ({ source: 'error' | 'dataSource' }) is exported by every package. The box is
attached only while a state shows (Web Component part state), from the header height down.
What it covers — the timeline, the splitter and the task-list rows — is inert meanwhile (out
of the tab order and the accessibility tree), and focus on one of them moves to Retry, else to
the toolbar. The task-list header row stays above the box: its first cell is the task list's
tab stop.
Charts that share a sync group keep one timeline: the date range (the union of their plans, so a date sits at the same x in every chart), the zoom level, the horizontal scroll and the task-list width. Create the group once, give it to every chart:
import { createGanttSyncGroup } from '@gantryjs/core'; // also re-exported by every package
const group = createGanttSyncGroup(); // or { zoom, scroll, range, panel }: each `true` by default<!-- Angular -->
<gantry [series]="design" [syncGroup]="group" />
<gantry [series]="build" [syncGroup]="group" [showToolbar]="false" />// Web Component
a.syncGroup = group;
b.syncGroup = group;<Gantry tasks={design} syncGroup={group} /> // React<Gantry :tasks="design" :sync-group="group" />
<!-- Vue --><Gantry tasks={design} syncGroup={group} /> <!-- Svelte -->- Range and task extent are unions over the members; zoom level, scroll and panel width are
last-writer-wins and go to every member but the one that reported them.
group.setLevel()andgroup.scrollTo()drive every member. - A chart that joins a group that already has a scroll position opens there instead of at
initialScroll. A scroll the group applied is never sent back, and a zoom level a chart doesn't have is ignored. Applying the group's panel width doesn't save the chart's state (a later save keeps the width shown), and Fit to screen fits the group's extent. nullorundefined(the default) is not synchronised. A chart leaves its group when it is destroyed or disconnected, or when the property changes.- Keep the group in a plain constant (Svelte:
constor$state.raw, never a deep$state): it is passed by identity and never copied.
options = {
editing: {
cells: true, // edit task-list cells: F2, Enter, double-click or typing
progress: { step: 5 }, // the progress grip and ↑ / ↓ in move mode, in % (`true` = step 5)
dependencies: { create: true, delete: true, parentChild: false }, // draw / delete links; parent ↔ child links
validate: (change, { tasks, proposed }) => (isAllowed(proposed) ? true : 'Not allowed'), // after the built-in checks
history: { limit: 100, coalesceMs: 800 }, // undo / redo; `false` keeps none
controlled: false, // true: the app owns the tasks (see below)
},
};editing is off by default. true turns on every feature with the defaults above; in the object form
every feature is on unless you set it to false (dependencies.parentChild is the exception: off
unless you set it). Drag and resize follow enableDrag / enableResize whatever editing is, but
every change — a drag, a resize, move mode, the progress grip, a cell, a link, the API, undo — goes
through the same pipeline once, so the events, the validation and the undo history are the same in
every binding. Try it in the Editing cases of the gallery.
| Option | Default | What it does |
|---|---|---|
editing.cells |
true |
The cells of editable columns open an editor (Cells) |
editing.progress |
true (step: 5) |
The progress grip on bars and ↑ / ↓ in move mode, snapped to step % |
editing.dependencies.create |
true |
Draw links from a bar's connector, or with L; the predecessors cell (Links) |
editing.dependencies.delete |
true |
Click an arrow, then Delete |
editing.dependencies.parentChild |
false |
Allow links between a task and its own ancestors or descendants |
editing.validate |
none | Your own check, after the built-in ones: return false or a message to reject the change; its context has { tasks, proposed, calendar } (the working calendar or null) |
editing.history |
true (limit: 100, coalesceMs: 800) |
Undo / redo: at most limit steps; consecutive keyboard, progress or cell changes of one task within coalesceMs are one step |
editing.controlled |
false |
The app owns the tasks: the chart applies nothing and keeps no history (Controlled mode) |
toolbar.undo |
true |
The toolbar's Undo / Redo buttons (shown while a history is kept) |
Every change is one GanttChange ({ source, updates, ops, column?, coalesceKey? }; each update has the
task's before and after fields), and runs through these steps:
- Validate. Built-in checks first — a link to itself, a duplicate link (one link per pair of tasks,
whatever its type), a link between a task and its own ancestor or descendant (unless
parentChild), a loop, an end before its start, a progress outside 0–100, a read-only task or field — thenediting.validate. A rejection stops here: nothing else fires, and the reason is announced (labels.announceChangeRejected; a cell shows it under the editor). before-change. Calldetail.preventDefault()in the handler, synchronously, to veto it. The Web Component's event is also cancelable (event.preventDefault()). A veto is announced too.- Apply and record. The tasks you passed are updated (
tasks,[(series)], or the data source throughpatch()) and the step goes on the undo stack. Withcontrollednothing is applied. - The events, in this order: one per op (
task-dragged,task-resized,task-progress-changed,dependency-created,dependency-deleted), thentask-updatedper changed task, thentasks-change, thenhistory-change(only when the undo state changed).
Ops come from what changed, not from the gesture: a date typed in a Start cell fires task-dragged as a
drag does, an End cell task-resized, the predecessors cell dependency-created / dependency-deleted,
and an undo fires the inverse events (source: 'undo'). A change that changes nothing fires nothing. With a
working calendar a move that keeps the working length is one task-dragged (no
task-resized) and one undo step, and daysMoved is the real calendar shift of the start.
A change started from inside one of these listeners (addDependency(), undo()…) is refused (false).
| Event (Angular output · React / Svelte callback · Vue) | detail |
|---|---|
before-change · (beforeChange) · onBeforeChange · @before-change |
GanttBeforeChangeEvent — { change, tasks, defaultPrevented, preventDefault() }; tasks are the proposed tasks |
task-dragged · (taskDragged) · onTaskDragged · @task-dragged |
TaskDragEvent — { taskId, oldStart, oldEnd, newStart, newEnd, daysMoved } |
task-resized · (taskResized) · onTaskResized · @task-resized |
TaskResizeEvent — { taskId, edge: 'start' | 'end', oldStart, oldEnd, newStart, newEnd, daysDelta } |
task-progress-changed · (taskProgressChanged) · onTaskProgressChanged · @task-progress-changed |
TaskProgressEvent — { taskId, oldProgress, newProgress } |
dependency-created / dependency-deleted · (dependencyCreated) / (dependencyDeleted) · onDependencyCreated / onDependencyDeleted · @dependency-created / @dependency-deleted |
DependencyChangeEvent — { fromId, toId, type, lag } |
task-updated · (taskUpdated) · onTaskUpdated · @task-updated |
TaskUpdatedEvent — { taskId, task, previous, fields, source } |
tasks-change · (tasksChange) · onTasksChange · @tasks-change |
GanttTasksChangeEvent — { tasks, change, applied }: the tasks now, or the proposed ones when applied is false |
history-change · (historyChange) · onHistoryChange · @history-change |
GanttHistoryState — { canUndo, canRedo, undoLabel, redoLabel, undoCount, redoCount } |
source is 'drag' | 'resize' | 'progress' | 'keyboard' | 'cell' | 'link' | 'delete' | 'api' | 'undo' | 'redo'.
applied is false in controlled mode and when your handler set the tasks itself during before-change.
TaskResizeEvent.handle ('left' | 'right') is deprecated: use edge. In controlled mode the op events and
task-updated still fire (with the proposed change), so you can log or save from them.
With editing: { controlled: true } the app owns the tasks. The chart validates each change, offers it to
before-change and reports it in tasks-change with the proposed tasks (applied: false); you set them
yourself. It applies nothing and keeps no history (undo is yours: GanttHistory and applyChange /
invertChange from @gantryjs/core are the pieces). This is how you approve a change asynchronously, or keep
the plan in a store.
// React
<Gantry
tasks={tasks}
options={{ editing: { controlled: true } }}
onBeforeChange={d => {
if (isLocked(d.change)) d.preventDefault();
}}
onTasksChange={d => setTasks(d.tasks)}
/><!-- Vue -->
<Gantry
:tasks="tasks"
:options="{ editing: { controlled: true } }"
@before-change="
d => {
if (isLocked(d.change)) d.preventDefault();
}
"
@tasks-change="d => (tasks = d.tasks)"
/>
<!-- const tasks = shallowRef(initial) --><!-- Svelte; let tasks = $state.raw(initial) -->
<Gantry
{tasks}
options={{ editing: { controlled: true } }}
onBeforeChange={d => {
if (isLocked(d.change)) d.preventDefault();
}}
onTasksChange={d => (tasks = d.tasks)}
/><!-- Angular -->
<gantry
[series]="tasks()"
[options]="{ editing: { controlled: true } }"
(beforeChange)="isLocked($event.change) && $event.preventDefault()"
(tasksChange)="tasks.set($event.tasks)"
/>// Web Component
chart.options = { editing: { controlled: true } };
chart.addEventListener('before-change', e => {
if (isLocked(e.detail.change)) e.preventDefault(); // or e.detail.preventDefault()
});
chart.addEventListener('tasks-change', e => (chart.tasks = e.detail.tasks));In the other modes the chart applies the change itself, so the handlers only need to react:
onTasksChange gives you the new tasks to keep in state (React setTasks(d.tasks)), and the Angular
series is a model — bind [(series)]="tasks" to get the applied tasks back (a one-way [series] works too). Setting the tasks inside before-change counts as handling
the change (applied: false, nothing recorded); in Angular do that with series.set() or a data-source write.
Svelte copies the tasks prop one level: keep app-owned tasks in $state.raw; in Vue use shallowRef.
| Set | What the user can no longer change |
|---|---|
task.editable: false |
Everything on that row: dates, progress, cells and links (pointer, keyboard, cell and API) |
task.draggable: false |
The dates only: move, resize and the Start / End / Duration cells. Progress, the other cells and links stay editable |
enableDrag / enableResize off |
Moving / resizing every bar (and the Start / End / Duration cells they edit) |
a parent with rollup.dates / rollup.progress |
Those derived fields |
| milestones | Resizing and progress |
Edits that are not allowed don't show their handles (no grip, no connector, no gantt-bar-draggable) and are
rejected whatever their source. They lock a bar's move and resize with editing off too.
el.undo(); // → boolean (false: nothing to undo, no history, rejected or vetoed)
el.redo();
el.canUndo; // Angular: gantt.canUndo() — a signal
el.clearHistory(); // task changes made elsewhere never clear it- Keys. Ctrl/⌘+Z undoes, Ctrl/⌘+Shift+Z and
Ctrl+Y redo, while the chart keeps a history and the focus is inside it — but not in
text entry, not inside an open cell editor and not in move mode, where the browser's own keys stay. The toolbar
has Undo and Redo buttons after the panel toggle (
toolbar.undo;aria-disabledwhen there is nothing to do, never in the More menu). - Steps. One change is one step, and undo restores the data's own shape (a string
dependencystays a string). Move-mode commits of one task, progress changes and edits of one cell merge into one step withincoalesceMs(800 ms); pointer drags never merge. The oldest steps drop pastlimit. A step whose tasks are gone is skipped. - Announcements. Undo and redo are announced with what they did (
labels.announceUndo(…),announceRedo(…));history-changecarriesundoLabel/redoLabel("Moved Design 2 days later") for your own buttons. updateTask()andappendTasks()stay silent data writes: nobefore-change, no events, no history. They write the raw input (tasksas you set it,[(series)], or the data source throughpatch()).
With editing.cells, a task-list cell of an editable column opens an editor on F2,
Enter, a double-click (row-dblclick still fires) or typing a character (it starts the text).
| Key | In the editor |
|---|---|
| Enter | Commit and close |
| Tab / Shift+Tab | Commit, then edit the next / previous editable cell of the row |
| Esc | Cancel |
| Focus leaves the cell | A valid value commits; an invalid one cancels, announced |
A commit is one 'cell' change through the pipeline (so before-change can veto
it and Undo reverts it). An invalid value keeps the editor open: the field gets aria-invalid, the message
shows under the cell and is read from the alert region. While cell editing is on, every cell has aria-readonly
("false" on the ones you can edit). The editor is named labels.ariaCellEditor(column, task).
| Built-in column | Editor and accepted text |
|---|---|
name |
Text, required (trimmed) |
start, end |
date, or datetime-local on hour scales and for dates with a time. A new start keeps the duration; an end can't precede the start. Date-only values stay date-only. With a working calendar a start snaps forward and an end back off non-working time |
duration |
5, 5d, 2w, 4h, 1.5d (1,5d too): more than 0; sets the end. Whole local calendar days plus real hours; with a working calendar, working days, the working week and hours of the working day |
progress |
Number from 0 to 100 (45 or 45%), step editing.progress.step |
predecessors |
Text, see below. The editor shows task ids (research FS+2d; scope SS); the cell shows names |
resources |
Text, see below: resources by id or name with optional units (Ana, Bo 50%); empty text unassigns |
name, start, end, duration, progress, predecessors and resources are editable by default (your own column over one
of those keys too: the key says which field it edits); every other column isn't, and id never is. The
field's rule applies on top: Start needs the bar to be movable, End and Duration resizable, Progress editing.progress,
Predecessors editing.dependencies.create. The Resources column isn't one of the default columns: list 'resources' in
panel.columns / table.columns (see Resources).
Predecessors. Entries are separated by ; or ,. Each is <task> [FS|SS|FF|SF] [±n[d|w|h]], case-insensitive: research,
research FS+2d, scope SS, a+2d, 2FS-1w. A task is an id, else a name that matches exactly one task (exact first,
then ignoring case). The type defaults to FS, the lag to 0 and its unit to days (w = 7 days, h = 1/24; with a
working calendar the lag counts working days, w is the working week and h an hour of the working day). Empty text
removes every link. An unknown task, a name shared by several tasks, a repeated link, a loop or a parent ↔ child link is
an error with its message. Ids that contain ; or , can't be referenced.
Resources. Entries are separated by ; or ,; each is a resource id, else a name (exact, ignoring case) and
optionally N% after it, the share of a full-time person the task needs per working day (Ana, Bo 50%; 100 when left
out). Empty text clears the assignments. An unknown resource, a name shared by several resources or units that aren't
above 0 are errors with a message (labels.errorUnknownResource, errorAmbiguousResource, errorRejected).
Custom columns.
columns = [
{
key: 'estimate',
label: 'Estimate',
width: 90,
editable: row => row.task.type !== 'milestone', // true | false | per row
editor: 'number', // 'text' | 'number' | 'date' | 'datetime-local' | 'select', or your own (below)
editorOptions: { min: 1, max: 80 }, // number: min / max / step; select: { options: [{ value, label }] }
exportValue: row => String(row.task.data?.['estimate'] ?? ''), // the editor's first value
parse: text => ({ data: { estimate: Number(text) } }), // the text → the task fields; throw Error('…') to reject
validate: patch => (Number(patch.data?.['estimate']) > 80 ? 'At most 80 hours' : null),
},
];Without parse, the text is written to task[key] when key is a GanttTask field, else to data[key] (a number
for 'number', a date for the date kinds; empty text clears it). In Angular the same fields are inputs of
ganttColumn: editable (also a bare attribute), editor, editorOptions, exportValue, parse, validate.
editor can be your own function. It gets a CellEditorContext (row, column, value, labels, invalid,
setValue(text), commit(move?), cancel()) and returns the editor's DOM node; call setValue() as the value
changes and commit() / cancel() to finish. The chart focuses the node (or its first focusable child) and
handles Enter, Tab and Escape unless the editor prevents them; chart shortcuts (+ − T F, Ctrl+A, Ctrl+Z) are ignored
inside an open cell editor.
// Web Component · React · Vue · Svelte: a function returning a node
const colour = ctx => {
const input = document.createElement('input');
input.type = 'color';
input.value = ctx.value;
input.setAttribute('aria-label', ctx.labels.ariaCellEditor('Colour', ctx.row.task.name));
input.addEventListener('input', () => ctx.setValue(input.value));
return input;
};
columns = [
{ key: 'colour', label: 'Colour', width: 70, editable: true, editor: colour, parse: v => ({ style: { color: v } }) },
];<!-- Angular: a template (import GanttCellEditorDirective next to GanttColumnDefDirective) -->
<gantry [series]="tasks" [options]="{ editing: true }">
<ng-container ganttColumn="colour" label="Colour" [width]="70" editable>
<ng-template ganttCellEditor let-edit>
<input #p type="color" [value]="edit.value" (input)="edit.setValue(p.value)" />
</ng-template>
</ng-container>
</gantry>- Without
GanttCellEditorDirectiveinimports, Angular takes the editor template for the cell template. A function ([editor]="fn") works too and wins over the template. - The node is mounted in the chart's shadow root in the Web Component and the wrappers: page CSS doesn't reach it, style
it inline (the
--gantt-*variables do). In Angular it is in the light DOM and gets the editor look through itsgantt-tl-editorclass. - An editor with several focusable fields must handle Tab itself (
preventDefault()and move the focus): the chart commits on Tab. editCell(taskId, columnKey)opens an editor from code, also on a row scrolled out of view. After a mouse click on your own button,focusTask(id)shows no focus ring on the bar (the browser's:focus-visiblerules).
With editing.dependencies, a bar shows a connector at each end while it is hovered, keyboard-focused or (touch) selected.
- Drag from a connector to another bar, or click (tap) a connector and then the target bar, or press L on a bar, move to another with the arrow keys and press Enter (or L).
- A link from A to B makes B depend on A (
B.dependencygains{ taskId: 'A' }). The type follows the ends: A's end to B's start is FS, end → end FF, start → start SS, start → end SF; the keyboard makes FS. A new link has no lag (edit it in the Predecessors cell). - While you draw, a dashed
path.gantt-link-previewfollows the pointer; an invalid target (the same task, a duplicate, a loop, a parent ↔ child link, a read-only task) is marked and announced, and letting go creates nothing. Escape cancels; a click on empty space cancels a click-mode link. - Delete: click an arrow (a 10 px band), then Delete or Backspace (
dependencies.delete); Esc deselects. The selected arrow isgantt-arrow--selectedand announced. addDependency(fromId, toId, type = 'FS', lag = 0)andremoveDependency(fromId, toId, type?)do the same from code (source'api', validated, vetoable, undoable); both return whether they ran.
While a link is drawn the Web Component matches :state(linking) and Angular's host has the class gantry-linking.
| Method (Angular: on the component) | What it does |
|---|---|
undo() / redo() → boolean |
Step through the history |
canUndo / canRedo |
Web Component getters; signals in Angular (gantt.canUndo()) |
clearHistory() |
Forget every step |
addDependency(from, to, type?, lag?) → boolean |
Link two tasks |
removeDependency(from, to, type?) → boolean |
Remove the links from from to to |
editCell(taskId, columnKey) → boolean |
Open a cell's editor (false: that cell can't be edited) |
focusTask(id, { area }) → boolean |
Focus a bar ('bar') or row ('row'); expands collapsed ancestors and scrolls to it |
With a data source, edits go through dataSource.patch(items => items.map(…)) (also GanttDataSource.patch() in Angular):
they replace the loaded items without a fetch and keep the paging state, so the edit survives the next page; reset()
drops them. PagedLoader.patch() is the same in @gantryjs/core.
- Angular
(rowClick)emits the clicked row (GanttFlatRow) from both panes, like every other binding, instead of itsRowGeometry:$event.row.taskbecomes$event.task. - Angular
seriesis a model ([(series)]);[series]alone still works. TaskResizeEventhas a requirededge: 'start' | 'end';handleis deprecated.- Hand-built objects need the new fields:
GanttLabels(24 required labels:undo,redo,ariaCellEditor, theannounce…anderror…ones),ToolbarLabels(undo,redo),BarVisual(progressGrip,connectors),ArrowVisual(points,selected),ToolbarItem(disabled),ResolvedToolbar(undo),BarOverlayLink(sourceSide) andBarHit('progress' | 'link-start' | 'link-end').options.labelsstays partial. - A rolled-up parent (
rollup.dates) and aneditable: falsetask no longer start a drag. - In the Web Component,
updateTask()/appendTasks()write the raw input (the data source'spatch()), as Angular does.
options = {
toolbar: { export: true }, // an Export menu in the toolbar (default false)
export: {
fileName: 'roadmap', // or format => `roadmap-${format}`; default 'gantt'
formats: ['svg', 'png', 'csv', 'print'], // the Export menu's items, in order
svg: { theme: 'light' }, // each format's defaults (tables below)
png: { scale: 2 },
csv: { delimiter: ';' },
print: { paper: 'a4', orientation: 'landscape', fit: 'width' },
},
};const svg = await chart.exportSvg({ range: 'visible' }); // '<svg …>…</svg>'
const csv = await chart.exportCsv({ columns: 'all' }); // CSV text
const png = await chart.exportPng({ scale: 3 }); // a Blob
await chart.download('png', { fileName: 'plan', theme: 'dark' }); // true; false when vetoed
await chart.print({ paper: 'letter', orientation: 'portrait' }); // true once the print dialog returnedExports are drawn again from the chart's model (@gantryjs/core), not copied from the screen: every shown
row, not only the ones mounted, with resolved colours and no CSS. Both renderers build the same snapshot, so the
Web Component (and React, Vue, Svelte) and Angular give byte-identical SVG and CSV for the same chart. Every method is
async; a call's options win over options.export.<format>, which wins over the defaults, and an invalid number
(a scale or limit ≤ 0, a negative margin or one that leaves no room on the paper, fewer than 1 page) keeps the
default. An export draws the chart as it is when the method is called: changing the tasks right after the call
doesn't change the file. Try it in the Export and print
cases of the gallery.
| Option | Default | What it does |
|---|---|---|
toolbar.export |
false |
The toolbar's Export menu: one item per format, running download() or print() |
export.fileName |
'gantt' |
A download's name, or a function of the format ('svg' | 'png' | 'csv'). Made safe on every OS (/ \ : * ? " < > |, control and bidi characters, leading and trailing dots and spaces removed; at most 120 characters; CON, NUL… get a _), extension added |
export.formats |
['svg', 'png', 'csv', 'print'] |
The Export menu's items, in order (unknown and repeated ones dropped; an empty list hides the button) |
export.svg |
below | exportSvg() and download('svg') defaults |
export.png |
below | exportPng() and download('png') defaults |
export.csv |
below | exportCsv() and download('csv') defaults |
export.print |
below | print() defaults |
Images (svg, png and print options):
| Option | Default | What it does |
|---|---|---|
view |
'auto' |
The view drawn: 'auto' = the one shown (task list and timeline in 'split', the timeline in 'chart', the task list in 'table'), or 'split' / 'table' / 'chart' |
range |
'all' |
'all' = the tasks' whole range (and the sync group's), whatever the chart's width; 'visible' = the part of the timeline on screen; { start, end } = those dates (the timeline is extended, then clipped) |
theme |
'current' (print: 'light') |
'current' = the palette shown (dark included); 'light' / 'dark' = that palette with the chart's theme overrides |
background |
'theme' |
'theme', 'transparent' or a CSS colour (SVG and PNG; print always uses the theme's) |
legend |
as the chart | The legend strip at the bottom (default: showLegend, when it has entries) |
today |
showToday |
The today line and its header pointer |
title |
labels.ariaChart |
The image's <title>; the print document's title (browsers use it as the PDF's name) |
description |
labels.exportDescription(count, start, end) |
The image's <desc> ("12 tasks from Mar 2, 2026 to Apr 30, 2026", dates in formats.dateFormat) |
customBars |
'render' |
'render' = custom bars as drawn on screen, 'builtin' = as built-in bars (see Custom bars) |
svg.scale |
1 |
The <svg>'s width / height times this (the viewBox stays) |
svg.idPrefix |
gantry-export-<n> |
Prefix of the image's ids (<prefix>-title, <prefix>-desc…); the default is new for every image |
png.scale |
2 |
Pixels per CSS pixel, lowered (in 0.01 steps) until the image fits the limits; below 1 the export fails with a RangeError |
png.maxSide |
16384 |
Largest width or height in pixels |
png.maxPixels |
16777216 |
Largest number of pixels (4096 × 4096, safe on iOS) |
CSV (csv options):
| Option | Default | What it does |
|---|---|---|
columns |
'visible' |
'visible' = the columns of the view shown (the split panel's in the chart view); 'all' = every built-in and custom column; or a list of keys: columns, or raw task fields such as parentId (unknown keys dropped) |
rows |
'all' |
'all' = every loaded task in display order, children of collapsed rows included; 'shown' = the rows shown |
values |
'data' |
'data' = values, 'display' = the cell texts (table below) |
delimiter |
',' |
',', ';' or '\t' |
header |
true |
A first line with the column labels |
bom |
true |
A UTF-8 byte order mark at the start of a downloaded file, so Excel reads it as UTF-8; exportCsv() never adds it |
neutralizeFormulas |
true |
Formula neutralisation (CSV injection) |
Print (print options; the image options too):
| Option | Default | What it does |
|---|---|---|
paper |
'a4' |
'a4', 'a3', 'letter', 'legal', 'tabloid', or { width, height, unit: 'mm' | 'in' } |
orientation |
'landscape' |
'landscape', 'portrait' or 'auto' (landscape when the drawing is wider than tall) |
margin |
10 |
Page margin in mm; twice the margin must stay under the paper's shorter side (a larger one keeps the default; a paper too small for the margin fails with a RangeError) |
fit |
'width' |
'width' = the whole width scaled to the page width (never enlarged, at least minScale), then pages across; 'page' = everything on one page; 'none' = actual size × scale |
scale |
1 |
Size with fit: 'none' |
minScale |
0.5 |
Smallest scale of fit: 'width' |
repeatHeader |
true |
The header tiers on every page |
repeatTable |
true |
The task list on every column of pages (see Print) |
pageNumbers |
true |
A footer "Title · Page n of N" (labels.printPage) |
maxPages |
200 |
More pages fail with a RangeError |
| Method (Angular: on the component) | Resolves | Rejects |
|---|---|---|
exportSvg(options?) → Promise<string> |
The <svg> element as text (no XML declaration: ready to inline) |
Only in <gantry-element>: when its export code can't be loaded (below) |
exportCsv(options?) → Promise<string> |
CSV text without the byte order mark | Only in <gantry-element>: when its export code can't be loaded (below) |
exportPng(options?) → Promise<Blob> |
An image/png Blob at scale, or the largest scale under it that fits the limits |
RangeError when it fits only below scale 1 (use exportSvg(), print() or a range); Error when the browser can't draw it |
download(format, options?) → Promise<boolean> |
true once the file is handed to the browser; false when before-export vetoed it. format is 'svg', 'png' or 'csv'; options are that format's plus fileName |
As exportPng() for a PNG |
print(options?) → Promise<boolean> |
true once the browser's print() returned; false when vetoed, or while another print of the chart runs |
RangeError over maxPages; Error when a page can't be drawn |
exportSvg(), exportCsv() and exportPng() return data and fire no event. download() and print() fire
before-export before any work: call detail.preventDefault() there, synchronously, to cancel (the Web
Component's event is also cancelable: event.preventDefault()); the call then resolves false and nothing else
happens. Angular emits (beforeExport) inside the call; <gantry-element> (and React, Vue, Svelte) loads its export
code on the first export (a lazy chunk) and fires before-export once it has loaded, so code right after the call
runs first. Either way the file is the chart as it was at the call. Then the file is made and saved (a temporary
<a download> with an object URL, revoked after 30 s) or the pages are printed, and export reports it. A failure
rejects the promise and fires no export; in <gantry-element> every method also rejects when its export code can't
be loaded (offline after a new deploy, say), and the next call tries again. An export started from the toolbar never
rejects: its failure goes to the alert region (labels.announceExportFailed('PNG image'), "Couldn’t export the PNG
image"; a print: labels.announcePrintFailed, "Couldn’t print the chart") and the console. Every run is announced:
labels.announceExportReady(fileName) ("roadmap.svg is ready"), labels.announcePrintReady(pages) ("Printing 3
pages"). Under a Content Security Policy, exportPng(), download('png') and print() need img-src blob:
(CSP).
| Event (Angular output · React / Svelte callback · Vue) | detail |
|---|---|
before-export · (beforeExport) · onBeforeExport · @before-export |
GanttBeforeExportEvent — { format, trigger, fileName, defaultPrevented, preventDefault() }; fileName has its extension, null for print |
export · (export) · onExport · @export |
GanttExportEvent — { format, trigger, fileName, size, pages }: size in bytes for a file, pages for a print (the other null) |
format is 'svg' | 'png' | 'csv' | 'print', trigger 'toolbar' | 'api'. The methods are on the element, so each
binding reaches them through its ref:
| Binding | Call |
|---|---|
| Web Component | await chart.download('svg') |
| Angular | chart = viewChild.required(GantryComponent), then await this.chart().download('svg') |
| React | const chart = useRef<GantryElement>(null), then await chart.current?.download('svg') |
| Vue | <Gantry ref="chart">, then await chart.value?.element?.download('svg') |
| Svelte | <Gantry bind:element={chart}>, then await chart?.download('svg') |
// Web Component: upload the SVG the toolbar would download
chart.addEventListener('before-export', e => {
if (e.detail.format !== 'svg') return;
e.preventDefault(); // synchronously; or e.detail.preventDefault()
void chart.exportSvg().then(upload);
});
chart.addEventListener('export', e => console.log(e.detail.fileName, e.detail.size));<!-- Angular -->
<gantry
[series]="tasks"
[options]="{ toolbar: { export: true } }"
(beforeExport)="$event.format === 'png' && $event.preventDefault()"
(export)="log($event)"
/>// React (Svelte: the same callback props; Vue: @before-export / @export)
<Gantry
ref={chart}
tasks={tasks}
options={{ toolbar: { export: true } }}
onBeforeExport={d => {
if (d.format === 'png') d.preventDefault();
}}
onExport={d => log(d)}
/>- Images (SVG, PNG, print) draw the view as a document: every shown row (collapse state and sort kept, none
left out by scrolling or virtualisation), the header tiers and the marker lane, row colours, non-working time (the
hatch as a
<pattern>), grid lines, annotations, the today line and pointer, dependency arrows (critical ones too), bars with their progress, labels, baselines, summary caps, rollup segments and critical tone, and the legend strip. Never the selection, hover, focus ring, grips, link connectors, previews, the drag ghost or readout, the tooltip, an open cell editor, the toolbar, the splitter or scrollbars. An empty chart draws the header andlabels.emptyState. With arange, the bars it doesn't reach are left out (no custom bar is rendered for them); links to them are still drawn. - The resource view (
rows: 'resources') draws one row per resource with its load as a stepped histogram, the capacity line and the part over it hatched (a second<pattern>, written only when a drawn row is over capacity), and no bars or arrows; see Resources. - Collapsed rows are left out of images, as on screen: call
expandAll()first, or (headless)createExportSource({ …, collapsedIds: [] }). A CSV has them by default (rows: 'all'). - Theme. Every colour is resolved for the export's palette: the file has presentation attributes (
fill,stroke,font-*) only — novar(, no<style>, no styling classes, noforeignObject, no script — so it opens as drawn in Illustrator, Inkscape, Office and an<img>; nothing in it is loaded from elsewhere. The header's current-period tint becomes the today colour at 14 % opacity. - The SVG is
role="img"with<title>and<desc>(aria-labelledby) andlang(the locale), every text a real<text>(searchable, selectable; a cut task-list cell keeps its full text in a<title>),data-task-idon rows and bars, the painters' class names (gantt-bar,g-tier…) for post-processing and numbers rounded to 2 decimals. A downloaded.svgstarts with the XML declaration;exportSvg()returns the<svg>element alone. - A data source exports the tasks loaded so far.
- Web Component, React, Vue, Svelte: while
renderBaris set, the export callsrenderBar(ctx, renderDefault)for every row;nullorundefineddraws the built-in bar, a node (a decoratedrenderDefault()too) is serialized into the file. - Angular: each row whose bar type resolves to a component other than the built-in ones (a subclass of a built-in
bar counts as custom; a built-in component reused for another
barTypedoesn't) is rendered off-screen once, serialized and destroyed. On the server custom bars are drawn as built-in bars. ctx.themeandctx.visualare the export's (its palette, paints resolved), and the bar is never selected or draggable. The markup keeps SVG drawing elements only (shapes, text, gradients, patterns, filters,a,use,image):script,style,foreignObject, animations (set,animate…),metadata, HTML and other namespaces are dropped with their content, and so areon*,tabindexandautofocusattributes,javascript:,vbscript:anddata:URLs (character references decoded;data:image/png,jpeg,gifandwebpstay), anyhrefbut a#fragmentor such an image, and anyurl(…)buturl(#…)(astylekeeps its other declarations): an export never loads anything from elsewhere. Thenvar(--gantt-*)is resolved with the export's theme (values escaped). Headless markup (createExportSource({ customBar })) follows the same rules (sanitizeSvgMarkupin@gantryjs/core).- Only attributes and inline styles travel: classes and stylesheet rules (page CSS,
::part(), an Angular component'sstyles) don't. Paint custom bars with attributes (afillfromresolvePaint(ctx.visual.paint.body, ctx.theme),[attr.fill]in an Angular template), or export them as built-in bars withcustomBars: 'builtin'(also the way out for an Angular bar that measures the DOM or must be attached to render).
RFC 4180: \r\n between records (none after the last), fields that hold the delimiter, ", CR or LF quoted, "
doubled. Values per column (values; a column's exportValue(row, ctx) wins in both modes):
| Column | 'data' (default) |
'display' |
|---|---|---|
id, name |
the text | the cell text |
start, end, baselineStart, baselineEnd |
local ISO: 2026-03-02 at midnight, else 2026-03-02T09:30 (:ss when not zero) |
the cell text |
duration |
days of the drawn span, a number (. decimal, at most 2 decimals); working days with a calendar |
the cell text |
progress |
0–100, a number | 45% |
predecessors |
ids: research FS+2d; scope SS (lags in working units with a calendar) |
names, as shown |
status, type |
the key (inProgress, milestone; a custom status as it is) |
the label |
variance |
signed days, a number (working days with a calendar) | the cell text |
capacity, load |
percent numbers on a resource row (load is the peak); empty on a task row |
100% |
resources |
the task's assignments by id: ana, bo 50% (units omitted at 100); empty on a resource row |
the cell text |
| your columns | render text, else task[key] or task.data[key] (a date as local ISO, an object as JSON) |
the cell text |
| a raw task field (no column has the key) | the field as the chart holds it, under the key (below) | the same |
columns: 'all' lists resources, capacity and load only when the chart has a use for them (see
Resources), so a chart without resources exports as before.
Raw task fields. A key of columns that no column has but that is a task field — parentId, dependency,
collapsed, barType, status, editable, draggable, barColor, rowColor, textColor, progressColor,
className (and id, name, start, end, progress, type when a column doesn't take them) — exports the field
as the chart holds it, with the key as its header, in both values modes: a date as local ISO, a number or boolean as
it is, dependency as predecessor ids (research FS+2d; scope SS, what the Predecessors cell reads back), an unset
field empty. So exportCsv({ columns: ['id', 'name', 'parentId', 'start', 'end', 'dependency'] }) carries the tree
and the links. Objects (baseline, style, data) are not fields here.
Formula neutralisation (neutralizeFormulas, OWASP CSV injection): a cell — header cells included — that starts
with =, +, -, @, tab or CR gets a leading ', unless it is a plain number (-3, +2.5, 1e3), so a
spreadsheet shows =HYPERLINK(…) as text instead of running it. It is applied before quoting.
print() paginates the drawing in core and prints it through a hidden about:blank frame built with DOM APIs (no
srcdoc, no HTML parsing: Trusted Types safe): each page is an <img> of an SVG page (alt = title and page), the
@page size and margin a constructable stylesheet, the footer real text; the document's title is title and its
lang the locale. It waits for every image, calls the frame's print(), then removes the frame on afterprint (or
after 60 s). Focus never moves into the frame.
- Fit.
'width'scales the whole width to the page width, never above 1 and not belowminScale, then adds pages across;'page'fits everything on one page (never enlarged);'none'prints atscale. The task list is never cut: the scale is lowered until it fits a page's width. - Breaks. Pages break only between rows (a row taller than a page gets a page of its own); a horizontal break moves back to a bottom-tier cell boundary when that keeps at least half the page. Pages run down, then across, at 96 px per inch.
- Repeats.
repeatHeaderdraws the header on every page (without it, only the first page of each column has it).repeatTabledraws the task list on every column of pages, but only while it takes at most half of the page's width: a wider task list is printed on the first column of pages only, and "Repeat task list" then changes nothing. - The legend goes on the last page of the first column (on a page of its own when it doesn't fit), wrapped at that
page's width.
pageNumbersreserves a 24 px footer. OvermaxPagesthe print fails with aRangeError("The print would take N pages, more than maxPages (M)…"): usefit: 'page', arangeor a largermaxPages. A margin that leaves no room on the paper (twice the margin at least its shorter side) fails with aRangeErrortoo. - Ctrl+P prints the page as it is. Both bindings carry
@media printrules: no toolbar, splitter, load-more footer, live regions, tooltip, readout, bar overlay, row hover or previews, exact colours, no scrollbars. But the chart is virtualised, so only the mounted rows print:print()prints every row. Your own print rules reach the Angular chart's light DOM (gantry .gantt-…); the Web Component's inner boxes needgantry-element::part(…).
The export functions are pure core functions: they run in Node, workers and SSR with no DOM.
createExportSource({ tasks, options }) builds the chart snapshot a binding would build (the same rows pipeline,
columns, styles, rollups and critical path), so the same input gives the same file:
import { writeFileSync } from 'node:fs';
import { createExportSource, exportChartCsv, exportChartPages, exportChartSvg, exportFileText } from '@gantryjs/core';
const source = createExportSource({
tasks,
options: { viewMode: 'Week', labels: { ariaChart: 'Roadmap' } },
now: new Date(2026, 0, 15), // the today line (default: the current time)
collapsedIds: [], // every row expanded (default: task.collapsed)
});
writeFileSync('roadmap.svg', exportFileText('svg', exportChartSvg(source, { theme: 'light' }))); // + XML declaration
writeFileSync('roadmap.csv', exportFileText('csv', exportChartCsv(source), { bom: true })); // + byte order mark
const { pages } = exportChartPages(source, { paper: 'letter' }); // one SVG per printed pagecreateExportSource also takes columns, annotations, dark, sort ({ key, dir }), view, panelWidth,
showLegend and customBar (a function of the bar's context returning SVG markup, or null for the built-in bar).
A PNG needs a canvas: in Node, turn the SVG into one with your own tool. In Angular, exportSvg() and exportCsv()
also work during server rendering (custom bars drawn as built-in), while exportPng(), download() and print()
reject with Error('Gantry: download() needs a browser') before beforeExport. The React, Vue and Svelte wrappers
render an empty <gantry-element> on the server: export there with core.
- Web fonts are not embedded. The SVG names the theme's
fontFamily; a PNG and a print draw the SVG as an image, which can't load web fonts, so the system's fallback font is used. EndfontFamilywith a generic family ('Inter, system-ui, sans-serif'). - Text is estimated. With no DOM to measure, an export may cut a label with an ellipsis slightly differently from the screen.
- Printing from a frame: iOS Safari may print the whole page instead of the chart; Firefox's
print()may return before its dialog closes, soprint()resolves early and a preview left open for more than 60 s loses its pages. Chromium is tested end to end. - Ctrl+P prints the mounted rows only;
print()prints every row. - A data source exports only the tasks loaded so far.
- A PNG is one image per call (no tiles): a plan larger than the canvas limits fails with a
RangeError— export an SVG, print it, or pass arange.
Everything user-facing is translatable. Dates are formatted with the platform's Intl API from a
BCP 47 locale and a dayjs-compatible pattern: no locale files to import, no runtime dependency.
options = {
locale: 'it', // month and day names → Italian
formats: { dateFormat: 'D MMM YYYY', readoutFormat: 'ddd D MMM' }, // columns and tooltip · drag readout
labels: { todayButton: 'Oggi', more: 'Altro' }, // any subset: the rest stays English
};locale: any BCP 47 tag ('it','de-CH','pt-BR','ja'); unknown or malformed tags fall back to'en', the default (deterministic output on every machine). The dayjs global locale is not read.- Format tokens:
YYYY YY·M MM MMM MMMM·Q·D DD Do·d dd ddd dddd·H HH h hh·m mm·s ss·A a·W WW(week number, see Week numbering) ·[literal text]. Names come fromIntl(CLDR), so a few abbreviations differ slightly from dayjs's locale files (GermanMär). labels: Partial<GanttLabels>overrides any UI string (buttons, tooltip, legend, header prefixesW/Q/H, duration text, column headers, aria-labels).formats.dateFormatformats the columns and the tooltip;formats.headeroverrides the header labels per zoom level;formats.readoutFormatformats the dates of the drag readout (default'D MMM', with the time on hour scales).- Angular also takes defaults for every chart below an injector level, see Internationalisation & date formats.
Time zones.
new Date('2026-01-05')(UTC midnight) is read as that calendar day in local time (normalizeDateOnly: true), so bars line up with day cells in every time zone;parseDate()helps too. ADatecan't say whether it was meant as a day or as an instant, so a time that happens to fall on UTC midnight (17:00 in Los Angeles or 20:00 in New York in summer) is read as that day too: for plans with hours west of UTC pass strings with a time ('2026-01-05T17:00') or setnormalizeDateOnly: false.- The x axis follows local calendar days, so daylight-saving changes never shift bars.
endDateMode: 'inclusive'makes a date-onlyendcover that whole day (default'exclusive').
Colours, font and radius come from the theme (options.theme, setTheme(), patchTheme(),
light/dark/auto). The theme reaches the pixels only through CSS custom properties:
| Variable | Theme key | Variable | Theme key | |
|---|---|---|---|---|
--gantt-bg |
backgroundColor |
--gantt-grid-line |
gridLine |
|
--gantt-header-bg |
headerBackground |
--gantt-grid-line-major |
gridLineMajor |
|
--gantt-bar |
barColor |
--gantt-today |
todayColor |
|
--gantt-bar-radius |
barRadius |
--gantt-weekend-bg |
weekendBg |
|
--gantt-milestone |
milestoneColor |
--gantt-holiday-bg |
holidayBg |
|
--gantt-critical |
criticalColor |
--gantt-off-hours-bg |
offHoursBg |
|
--gantt-arrow |
arrowColor |
--gantt-intensive-bg |
intensiveBg |
|
--gantt-text / -sec / -muted |
text colours | --gantt-sel-bg / --gantt-sel-border |
selection | |
--gantt-progress-overlay |
progressOverlay |
--gantt-today-label |
derived: current-period label | |
--gantt-focus-ring |
focusRing |
--gantt-tooltip-bg / -bdr / -txt |
tooltip | |
--gantt-row-hover-bg |
rowHoverBg |
--gantt-overallocated |
overallocatedColor |
-
Focus rings. Every keyboard focus ring — toolbar controls, task-list rows, cells and header cells, the splitter and its toggle, the timeline viewport and the focused bar — is drawn in
--gantt-focus-ring(theme keyfocusRing:#2563EBlight,#60A5FAdark,#3B82F6inLegacyLightTheme; the values ofselectionBorder, so the default look is unchanged).--gantt-sel-borderstays the outline of the selected bar and row and the splitter / column resize hover. -
Milestones. The light theme's
milestoneColoris#7C3AED(a white label reads 5.7:1 on it);LegacyLightThemekeeps the 1.x#8B5CF6. -
Paints. Every colour of the render model is a
Paint: a theme token such asvar(--gantt-bar), or an explicit colour (task.barColor, a holidaycolor,arrows.color…) passed through as is. Both bindings apply paints as CSS properties (style.fill, never a presentation attribute), so a value like'red;background:url(x)'can't inject CSS. -
Where the tokens live. The Web Component writes them into a
:hoststylesheet in its shadow root, so page CSS on the element overrides them and a stringstyleattribute no longer wipes them. Angular binds them as an inline[style]map on the<gantry>host (page CSS needs!importantthere; prefersetTheme()or a template[style]binding, see Angular theming).gantry-element { --gantt-bar: #6366f1; --gantt-bar-text-on-bar: #ffffff; /* keep the label readable on the new fill */ }
-
Contrast tokens. Labels inside theme-coloured bars use three derived tokens:
--gantt-bar-text-on-bar,--gantt-bar-text-on-milestoneand--gantt-bar-text-on-critical, eachreadableOn(fill, barTextColor)(white or near-black whenbarTextColoris unreadable on the fill). The current-period header label uses a fourth,--gantt-today-label(todayLabelColor(theme)):todayColorwhen it reaches 4.5:1 on the 14 % today tint overheaderBackground, elsetodayColormixed towardstextPrimaryuntil it does (#964B13in the light theme; the dark theme keeps#FBBF24).setTheme()/patchTheme()recompute them; a page-CSS override of--gantt-bar,--gantt-milestone,--gantt-criticalor--gantt-todaymust also set the matching token. Labels on an explicittask.barColorget a concrete readable colour;task.textColorandbar.label.coloralways win. -
Row hover. The row under a mouse or pen pointer is tinted in both panes with
--gantt-row-hover-bg(theme keyrowHoverBg:rgba(15, 23, 42, 0.04)light,rgba(148, 163, 184, 0.08)dark;transparentturns it off). It clears on leave, scroll and drag start, and is hidden in forced colours. -
Over-allocation.
overallocatedColor(--gantt-overallocated:#C2410Clight,#FB923Cdark, the legacy palettes inherit them; at least 3:1 on the row backgrounds) paints the resource load above a resource's capacity, hatched, so it never relies on colour alone. -
Progress overlay. Bars without
task.progressColor/bar.progress.colordarken their progress part with--gantt-progress-overlay(theme keyprogressOverlay,#000000) atbar.progress.opacity. -
Metrics.
barRadius(the defaultbar.radius),fontSize(bar labels usefontSize − 1) andbarTextColorshape the layout, so change them through the theme API, not page CSS:themeMetrics(theme)returns them andresolveConfig(options, i18n, themeMetrics(theme))reads them. The resolved config holds no colours, so a colour-only theme change leaves the layout as it is. -
Every token. All 44 custom properties (the 40 theme keys and the four derived tokens) are listed in the Angular token map and the Web Component's CSS custom properties.
-
Custom renderers.
ctx.visual.paint.{body,progress,label}may bevar()strings: apply them as style properties (el.style.setProperty('fill', paint)), or get a concrete colour withresolvePaint(paint, ctx.theme)(ctx.themeis the chart's resolved theme).themeToCss(theme)lists every custom property a theme writes, the contrast tokens included.
The chart publishes its layout as custom properties on its host, to size your own overlays or
cells against it. They are outputs: set rowHeight, bar.height and density through the options.
| Hook | Value |
|---|---|
--gantt-row-height |
the resolved row height (36px by default) |
--gantt-bar-height |
the resolved bar height (21.6px by default) |
data-density |
compact · normal · comfortable |
data-narrow |
present while the chart is narrower than responsive.breakpoint |
On the Web Component the two variables live in the host's theme sheet and data-density /
data-narrow on the wrapper part (inside the shadow root, so page CSS can't select them: use
the variables, or the density and responsive options). On Angular all four are on the
<gantry> host, together with --gantry-control-size (the toolbar's control size).
| Hook | Web Component <gantry-element> |
Angular <gantry> |
|---|---|---|
| Theme tokens | --gantt-* on the element (page CSS wins) |
--gantt-* in [style] (needs !important) |
| Inner boxes | ::part(wrapper | toolbar | panel | splitter | timeline | legend | state) |
the .gantt-* classes (light DOM) |
| Dark theme | :state(dark) |
class gantry-dark |
| Bar dragged | :state(dragging) |
class gantry-dragging |
| Loading | :state(loading) |
class gantry-loading |
| Link drawn | :state(linking) |
class gantry-linking |
React, Vue and Svelte render a <gantry-element>, so they use the Web Component column.
- Web Component: works under
style-src 'self'; script-src 'self'; require-trusted-types-for 'script'with no'unsafe-inline'or'unsafe-eval'. The shadow tree is built node by node (noinnerHTML), the CSS is one constructable stylesheet shared by every instance plus a theme sheet per instance, and every style is a CSSOM write. A strictstyle-srcneeds constructable stylesheets (Chromium 73+, Firefox 101+, Safari 16.4+); there is no nonce option. Details in its README. - Angular: the chart binds styles through the CSSOM (
[style]maps, neverstyle=""strings or HTML parsing), so it adds no inline-style requirement of its own. Angular puts the component styles in<style>elements: give them the nonce withngCspNonceon the root element or theCSP_NONCEtoken (providers: [{ provide: CSP_NONCE, useValue: nonce }]). - React, Vue and Svelte inherit the Web Component's policy.
- Exports (every binding):
exportPng(),download('png')andprint()draw the SVG from ablob:URL, so they needimg-src blob:(data:is not needed); SVG and CSV downloads need nothing. No Trusted Types sink is used (noinnerHTML,srcdocorDOMParser), and the print frame's CSS is a constructable stylesheet. The Web Component loads its export code on the first export (import()of a chunk next to the element's), soscript-srcmust allow where the element is served from.
-
Angular: SSR-safe, the HTML has the rows, bars, theme tokens and the
regionrole (see Server-side rendering). -
React, Vue, Svelte:
renderToString/ streaming emit the<gantry-element>tag with your class, style and children and touch no DOM API; the chart renders once the element is defined and the component hydrates. Reserve the box until then:gantry-element:not(:defined) { display: block; width: 100%; height: 100%; }
-
Web Component alone: importing
@gantryjs/vanillaon the server is safe (the module top level guardsdocument/HTMLElement); the chart paints in the browser. -
Exports in Node:
createExportSource()withexportChartSvg(),exportChartCsv()orexportChartPages()from@gantryjs/core(see On the server and in Node).
Thirty-five runnable cases (styles, rollup, synced charts, responsive layouts, theming, lazy loading, editing, export and print, working calendars, resource load…), each in all five frameworks with the same controls and a code tab. A "Known differences" section on the Frameworks page lists what a framework does differently.
- Angular (the site):
/examples - Web Component:
/wc/examples.html - React:
/react/#/examples· Vue:/vue/#/examples· Svelte:/svelte/#/examples
options = {
keyboard: { characterShortcuts: true }, // false turns off + − T F (WCAG 2.1.4)
};The chart's host is never a tab stop itself (the Web Component's shadow root delegates focus,
so el.focus() focuses the first stop inside). Tab moves through, in order:
- the toolbar: one tab stop, the arrow keys move between its controls;
- the task list (
role="treegrid"): one tab stop (tabindex="0") — the header cell or cell focused last, else the focused row, else the row focused last while it is mounted, else the first row in view, else (no rows: loading, empty, a failed first page) the first header cell. Every other row, cell and header cell, the collapse toggles and the sortable headers havetabindex="-1"; - the splitter (
.gantry-splitter-handle,role="separator"), then its collapse toggle; - the bars: one tab stop — the bar of the focused row (in either pane), else the bar
focused last while it is mounted, else the first bar in view. The timeline viewport
(
role="group") is a tab stop only while no bar is mounted; its arrow keys then scroll it.
A focused row or bar stays mounted while it scrolls out of view (.gantt-tl-row--pinned), so
focus survives scrolling, and both panes' tab stops follow the row that last had focus. Every
stop shows a ring in --gantt-focus-ring on keyboard focus (the bar's is drawn around it in
g.gantt-bar-overlay, with its resize grips), never hidden under the sticky header: the task
list has scroll-padding-top = the header height, and the timeline adds the reach of a bar's
ring on every side (core timelineScrollPadding), so a focused bar lands with its whole ring in
view. When the focused control disappears (its row was removed or collapsed away), focus moves
to the bars' tab stop (the viewport while no bar is mounted; in the table view, the task list's
tab-stop row; while a state box covers the body, its Retry, else the toolbar). Pressing a draggable bar with the
mouse focuses the timeline viewport, so Escape cancels the drag.
| Focus on | Key | Action |
|---|---|---|
| Toolbar | ← / → (wrapping), Home / End |
Move between the controls |
Enter / Space |
Press the button, check the radio | |
| Task-list row | ↑ / ↓ |
Previous / next row (the header row is above the first row) |
PgUp / PgDn · Home / End |
One page up / down · first / last row | |
→ |
Collapsed group: expand it; otherwise move into the row's first cell | |
← |
Expanded group: collapse it; otherwise go to the parent row | |
Enter |
Select only this row and fire row-click |
|
Space · Shift+Space · Ctrl/⌘+Space |
Select (toggles with multiSelect) · extend the range · toggle |
|
| Task-list cell | ← / → |
Previous / next cell; ← on the first cell goes back to the row |
↑ / ↓ · PgUp / PgDn |
Same column, other row | |
Home / End · Ctrl/⌘+Home / End |
First / last cell · same column, first / last row | |
Enter, Space |
As on the row (a button or link inside a cell keeps them) | |
| Header cell | ← / → · ↓ |
Previous / next header cell · the first row, same column |
Enter / Space |
Sort by the column | |
| Bar | ↑ / ↓ · PgUp / PgDn · Home / End (Ctrl/⌘ too) |
Focus the bar of the previous / next row, one page away, the first / last row (scrolled into view) |
← / → |
Pan the timeline by one bottom-tier header cell | |
Space · Shift+Space · Ctrl/⌘+Space |
As in the task list | |
Enter / F2 |
Move mode on a bar that can move, resize or change progress; otherwise select only this row and fire row-click |
|
L |
With editing.dependencies: start a link from this bar; Enter / L on another bar creates it, Escape cancels |
|
| Move mode | ← / → |
Move the bar one unit earlier / later (a day; an hour on hour scales; a working day with a working calendar) |
↑ / ↓ |
With editing.progress: progress up / down by step % |
|
Shift+← / → · Alt+← / → |
Resize the end · the start (task bars, with enableResize) |
|
Enter / F2 |
Commit: task-dragged / task-resized / task-progress-changed as after a pointer drop, then the change is announced |
|
Escape, Tab, leaving the bar, a pointer press |
Cancel (announced) | |
| Task-list cell | F2 · Enter · a character |
With editing.cells: open the cell's editor (a character starts the text) |
| Cell editor | Enter · Tab / Shift+Tab · Escape |
Commit · commit and edit the next / previous cell · cancel |
| Selected arrow | Delete / Backspace · Escape |
Remove the link · deselect it |
| Splitter | ← / → · Enter / Space |
Resize the panel · collapse / expand it |
| Anywhere | Escape |
Cancel move mode, a drag, a link or a cell edit, else close the tooltip, else clear the selection |
Ctrl/⌘+Z · Ctrl/⌘+Shift+Z / Ctrl+Y |
With an editing history: undo · redo (not in text entry or move mode) | |
Ctrl/⌘+A (also with Shift) |
Select all visible rows | |
+ (or =) · - · T · F |
Zoom in · zoom out · scroll to today · fit to screen — off with characterShortcuts: false |
- Move mode works like a pointer drag: the same snapping and minimum length, the drag ghost, the
readout chip with the new dates, and an announcement of each step; a movable bar is one with
enableDragon, nottask.draggable: falseoreditable: false, and its dates not rolled up. Withediting.progress,↑/↓change the progress too, and the key help says so. While it is on, other plain keys do nothing. - Shortcuts never fire while typing in an input, a select or a
contenteditablecell, nor withAlt/AltGrheld, nor inside an open cell editor; keys the chart doesn't use keep their browser default.L,DeleteandCtrl/⌘+Z/Yact only when their editing feature is on, and keep their default action otherwise. - In a right-to-left layout the toolbar's
←and→swap meaning.
- Mouse. A press on a bar becomes a drag once the pointer has moved 3 px; a smaller move is
a click, which selects the row. The cursor follows the part of the bar under the pointer:
grabon a draggable bar's body,ew-resizeon its handles,grabbingwhile it moves. - Touch and pen. A press held still for 350 ms (within 8 px) picks the bar up; moving
earlier scrolls the timeline as usual (
touch-action: pan-x pan-yon the canvas), and a tap toggles the bar's tooltip. While a bar is dragged the page doesn't pan, and the long-press menu and text selection are off. A second finger cancels a press that hasn't become a drag. - Auto-scroll. Dragging a bar within 32 px of the timeline viewport's left or right edge scrolls it (up to 18 px a frame), and the bar keeps following the pointer.
- Resize handles. Every resizable task bar at least 12 px wide has a pointer target at each
end,
bar.handles.hitSizepx wide (default 24, the WCAG 2.5.8 target size), centred on the edge: it reaches at most a third of the bar inward, stays inside the row, and the two never overlap. Their grips show on the bar under a mouse or pen, on the keyboard-focused bar and, on touch screens, on the selected bars. Milestones are hit 3 px around their shape, summary bars down to their end caps. - Progress grip and connectors (with
editing). The hovered bar, the keyboard-focused bar and, on touch screens, the selected bars also show a progress grip (rect.gantt-progress-grip) and a link connector at each end (rect.gantt-link-connector); every target is at least 24 px both ways and stays inside its row. Drag the grip to change the progress (a live fill, snapped toediting.progress.step, one undo step on release), drag from a connector to another bar to link them, or tap a connector and then the target. A click on a dependency arrow selects it. The cursor follows the part:ew-resizeon the grip,crosshairon a connector and while linking,pointerover an arrow. Grips and connectors are hit only where they show, so a press inside a narrow bar still moves it. - Cancel.
Escape,pointercanceland removing the chart cancel a drag: the bar goes back, and the click that ends it selects nothing. - Both bindings hit-test bars by geometry (core
hitTestBaron the row under the pointer), so a custom bar is hit by its visual box.
- Ctrl/⌘ + wheel over the timeline, and a trackpad pinch (browsers send it as a wheel with Ctrl): one zoom level per 100 px of scrolling. The plain wheel still scrolls, and over the task list Ctrl/⌘ + wheel is left to the browser (page zoom).
- Two-finger pinch on a touch screen: one level per ×1.25 of finger spread, around the fingers' midpoint. Safari's gesture events (its trackpad pinch, iOS) zoom the same way.
- Gestures keep the date under the pointer in place; the toolbar, the
+/-keys andzoomIn()/zoomOut()/setViewMode()keep the centre. Nothing zooms while a bar is being moved. Every level change firesview-mode-change/viewModeChangeand is announced (labels.announceZoom: "Zoom: Week"). - Fit to screen: the toolbar's Fit button (
toolbar.fit), theFkey andfitToScreen()switch to the finest of thezoomLevelsat which every task fits the viewport (with a 32 px margin; the coarsest level when none does), then scroll the earliest task near the left edge. Nothing happens without tasks or before the chart is laid out.
- The host is a
regionlandmark named bylabels.ariaChart(the Web Component throughElementInternals, withrole/aria-labelhost attributes as the fallback; Angular with host attributes),aria-busywhile the chart loads. - The task list is a
treegridnamed bylabels.ariaTaskList(aria-multiselectablewith multiple selection,aria-colcount): the header row isaria-rowindex="1"; rows carryaria-rowindex,aria-level,aria-posinset/aria-setsize(their place among their siblings),aria-selectedand, for groups,aria-expanded; cells and header cellsaria-colindex. - The timeline
<svg>is alistboxnamed bylabels.ariaBars("Task bars";aria-multiselectable,aria-orientation="vertical"). Every bar, custom ones included, is anoptionnamed bytaskAccessibleName()(name, type, dates, progress, critical path, baseline), with thearia-roledescriptionlabels.ariaTask,ariaMilestone,ariaGroupor, for a custombarType,ariaBarRole("task bar", "milestone", "summary bar", "bar"),aria-selected, andaria-posinset/aria-setsizecounting all visible rows ("3 of 40"). While its tooltip shows, the bar isaria-describedbyit. - The toolbar is a
toolbar(labels.ariaToolbar); its zoom levels and views areradiogroups ofradios witharia-checked. The splitter is aseparatorwitharia-valuenow(the panel width in px),aria-valuemin(0 when the panel is collapsible, elsepanel.minWidth) andaria-valuemax(the widest panel it allows), next to its collapse toggle (labels.showPanel/hidePanel). - A polite live region (
.gantt-sr-live,role="status") announces changes of the number of selected tasks, the data source's loading (labels.loading, thenannounceLoaded(n)/allLoaded(n)), zoom levels, expanding and collapsing from the keyboard, and move mode (its key help, each step, the commit or the cancel). Announcements are debounced: 250 ms after the last change only the last message is read, and a repeated message is read again. Errors go to.gantt-sr-alert(role="alert") at once — once: while the error state's box (itselfrole="alert") shows, that region stays empty. Every text is alabelsentry (announceSelection,announceZoom,announceExpanded,announceEditStart, …). - With
editing, every applied change is announced in words ("Design moved 2 days later", "Linked Research to Build"), and so are a rejected or vetoed one (announceChangeRejected), undo and redo (announceUndo/announceRedo), a link started, selected or deleted, and a cell edit's invalid value (the alert region). Task-list cells carryaria-readonlywhile cells are editable; the cell editor is named bylabels.ariaCellEditor(column, task),aria-invalidandaria-describedbyits message. - Exports: a download is announced (
labels.announceExportReady, "roadmap.svg is ready"), and so is a print (announcePrintReady, "Printing 3 pages"); a failed export from the toolbar goes to the alert region (announceExportFailed, orannouncePrintFailedfor a print). The Export button is a menu button (aria-haspopup="menu",aria-expanded), its menu amenuofmenuitems. An exported SVG isrole="img"with a<title>, a<desc>and its texts as text; a printed page is an image whosealtis the title and the page number.
Under @media (forced-colors: active) (Windows High Contrast and other forced palettes) both
bindings paint with system colours; the look outside that mode doesn't change.
| Part | Forced colour |
|---|---|
| Bars · their progress | ButtonFace with a ButtonText outline · ButtonText |
| Critical bars | dashed outline |
| Selected bar, selected row, current period, today, checked toolbar radio, every focus ring | Highlight (text on it HighlightText) |
| Bar labels | CanvasText outside a bar, ButtonText inside |
| Arrows | CanvasText |
| Grid lines, annotations, non-working time, loading placeholders | GrayText |
| Tooltip, drag readout, state boxes | Canvas / CanvasText with a border |
| Progress grip, link connectors, the link being drawn, the selected arrow | Highlight |
| Invalid link targets, an invalid cell editor | dashed CanvasText |
| Cell editor | Field / FieldText |
| Disabled Undo / Redo | GrayText |
| The More or Export button while its menu is open | Highlight |
A custom renderBar SVG and custom Angular bar components keep their own paints unless they add
forced-colors rules of their own (built-in bars on the critical path carry the class
gantt-bar--critical). The focus ring and the grips of every bar, custom ones included, follow
the forced palette.
Angular 22+ — zoneless, signal-based, SSR-safe, zero
@angular/animations, zero@angular/cdk. npm package:@gantryjs/angular(formerlyngx-gantry) · component:<gantry>· class:GantryComponent.
npm i @gantryjs/angular
# peer deps: @angular/core & @angular/common ^22 (@gantryjs/core is installed automatically)| Feature | Detail |
|---|---|
| Signals | State in signal() / computed() / linkedSignal(), the zoom level a model() ([(viewMode)]) — no RxJS |
| Zoneless | Works with provideZonelessChangeDetection(), no zone.js |
| SSR-safe | No browser globals; observers, timers and listeners start after the first browser render |
| Custom bars | Any component extending GanttBarBase, provided per application, route or component with provideGanttBar() |
| Virtual scroll | Rows windowed vertically; header cells, bands and grid lines horizontally — zero CDK |
| Lazy load | Paged GanttDataSource over the core PagedLoader: accumulates pages, retries automatically, error state with Retry |
| Drag & resize | Pointer Events for bars, the splitter and column widths: 3 px mouse threshold, 350 ms touch long-press, auto-scroll at the edges, 24 px resize targets |
| Selection | Single / Ctrl+Click toggle / Shift+Click range, and the same with Space / Ctrl+Space / Shift+Space |
| Editing | options.editing: cell editors, a progress grip, drawn links, undo / redo and a cancelable (beforeChange) — see Editing |
| Export & print | exportSvg(), exportPng(), exportCsv(), download(), print(), a toolbar Export menu and (beforeExport) — see Export and print |
| Keyboard | One tab stop each for the toolbar, the task list (row-first treegrid) and the bars; keyboard move / resize mode; + - T F shortcuts |
| Accessibility | region landmark, treegrid task list, bars as a listbox, debounced live regions, forced colors, WCAG 2.2 AA palette |
| Sorting | Click a sortable column header: ascending → descending → original order |
| Tooltip | One native popover per chart (Popover API), on hover, focus and tap, <ng-template ganttTooltip> content — zero CDK Overlay |
| Zoom | Toolbar, + / -, Ctrl/⌘ + wheel and pinch at the pointer, fit to screen (F, fitToScreen()) |
| States | loading skeleton, errorMessage with Retry (retry output), [ganttEmpty] content |
| Dark / light | Auto prefers-color-scheme + manual toggle |
| Animations | CSS transitions (panel, table view), off with prefers-reduced-motion |
| Dependencies | None at runtime besides @gantryjs/core |
// app.component.ts
import { Component, signal } from '@angular/core';
import { GantryComponent, ViewMode, type GanttTask, type TaskDragEvent } from '@gantryjs/angular';
@Component({
selector: 'app-root',
imports: [GantryComponent],
template: `
<gantry
style="height: 480px"
[series]="tasks"
[options]="{ rowHeight: 36, baseline: true }"
[(viewMode)]="zoom"
showLegend
(taskDragged)="onDrag($event)"
/>
`,
})
export class AppComponent {
/** Two-way: the toolbar, the + / - keys and setViewMode() write the level back */
readonly zoom = signal<string | undefined>(ViewMode.Month);
readonly tasks: GanttTask[] = [
{ id: '1', name: 'Discovery', start: new Date('2026-01-05'), end: new Date('2026-01-31'), progress: 100 },
{
id: '2',
name: 'Build',
start: new Date('2026-02-01'),
end: new Date('2026-03-15'),
progress: 40,
dependency: '1',
},
];
onDrag(event: TaskDragEvent): void {
console.log(`${event.taskId} moved by ${event.daysMoved} days`);
}
}<gantry> needs no provider: the built-in bars, the English labels and the light theme work as
they are. Give it a height (or a flex parent): the task list and the timeline scroll inside it.
provideGantt() is optional and sets defaults for every chart below the injector level it is
provided at (see Providers & setup):
// main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { provideZonelessChangeDetection } from '@angular/core';
import { provideGantt } from '@gantryjs/angular';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, {
providers: [provideZonelessChangeDetection(), provideGantt({ darkMode: 'auto', locale: 'it' })],
});A custom bar is a component that extends GanttBarBase: an abstract directive that declares the
ctx input, so the bar only reads this.ctx() (don't declare your own ctx).
import { ChangeDetectionStrategy, Component, computed } from '@angular/core';
import { GantryComponent, GanttBarBase, provideGanttBar, type GanttTask } from '@gantryjs/angular';
@Component({
selector: 'g[epicBar]',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<svg:rect
[attr.x]="geo().barX"
[attr.y]="geo().y + 6"
[attr.width]="geo().barWidth"
[attr.height]="geo().height - 12"
rx="8"
[style.fill]="ctx().visual.paint.body"
/>
<svg:text
[attr.x]="geo().barX + 8"
[attr.y]="geo().y + geo().height / 2"
[style.fill]="ctx().visual.paint.label"
font-size="12"
dominant-baseline="central"
>
⚡ {{ task().name }}
</svg:text>
`,
})
export class EpicBarComponent extends GanttBarBase {
protected readonly geo = computed(() => this.ctx().geometry);
protected readonly task = computed(() => this.ctx().row.task);
}
@Component({
selector: 'app-plan',
imports: [GantryComponent],
// The charts of this component draw barType 'epic' with EpicBarComponent; other pages don't see it
providers: [provideGanttBar({ type: 'epic', component: EpicBarComponent })],
template: `<gantry style="height: 360px" [series]="tasks" />`,
})
export class PlanComponent {
readonly tasks: GanttTask[] = [
{ id: 'e1', name: 'Payments', barType: 'epic', start: new Date('2026-02-02'), end: new Date('2026-04-30') },
];
}ctxis aGanttBarContext:row(the task and its tree position),geometry,visual(the@gantryjs/corebar model: path, label, progress, baseline and the paintsvisual.paint.{body,progress,label}, which may bevar(--gantt-*)tokens — bind them as styles or useresolvePaint(paint, ctx.theme)),theme,isSelected,isCritical,draggableandshowBaseline. The chart keeps the samectxobject while none of them change, so only the bars whose data changed re-render.- The chart creates each bar's
<g>host in the SVG namespace itself (whatever the selector) as a direct child ofg.gantt-bars-layer, and dims it while it is dragged. Prefix the SVG elements of the template withsvg:. - The chart also stamps the host with the bar's accessibility, as for the built-in bars and the
Web Component's:
role="option"of the timeline'slistbox,aria-label(taskAccessibleName()),aria-roledescription(labels.ariaBarRole, "bar", for a custom type),aria-selected,aria-posinset/aria-setsize, the rovingtabindex,data-task-id,data-bar-typeanddata-focus-key. Don't setroleoraria-labelon your bar: the chart's values win (see Screen readers). - Drag, resize, selection, the keyboard, the tooltip, the focus ring and the resize grips use
the row's geometry (
ctx.visual.hitBox,focusRect,handles), so they work on any bar. - In forced colors, the built-in bars' system-colour rules don't reach a custom
component (style encapsulation): add a
@media (forced-colors: active)block to its styles. - Provide bars in
bootstrapApplication, a route'sprovidersor a component'sproviders(provideGanttBar(...defs), orprovideGantt(config, withBars(...defs))). Each chart resolves a type from the nearest level that defines it, so a level can also replace a built-in type (task,milestone,group); an unknownbarTypedraws thetaskbar. The built-in bars are exported (GanttTaskBarComponent,GanttMilestoneComponent,GanttGroupBarComponent) to reuse them for another type:provideGanttBar({ type: 'phase', component: GanttGroupBarComponent }).
const ds = new GanttDataSource({
fetcher: (page, size) => fetch(`/api/tasks?page=${page}&size=${size}`).then(r => r.json()),
pageSize: 30, // fewer items than this = last page
initialPage: 1, // 0 = load nothing until ds.loadNext()
retries: 2, // automatic retries of a failed page, backoff from retryDelay (500 ms)
});
// Template:
<gantry [dataSource]="ds" (loadError)="report($event.error)" />The next page loads as the last rows scroll into view. While the first page loads the chart
shows its loading state. A page that still fails after its
retries sets ds.error(), emits loadError / load-error / onLoadError ({ error, page })
and pauses loading until ds.retry(): a failed first page shows the chart's error state, whose
Retry also emits retry ({ source: 'dataSource' }); a later page shows a message with a
Retry button in the task-list footer. ds.reset() reloads from the first page;
ds.setStatic(items) shows a fixed list. createGanttDataSource(config) is the same as
new GanttDataSource(config); neither needs an injection context. The Web Component takes the same config (gantt.dataSource = { fetcher, pageSize }).
The task list has two faces. Next to the timeline (view: 'split') it is a lean panel;
in the full table view (view: 'table', toolbar "Table") it shows every useful field:
| View | Default columns |
|---|---|
| split panel | name · start · end · progress |
| table view | name · type · status · start · end · duration · progress · predecessors (+ baselineStart · baselineEnd · variance with baseline: true) |
Built-in keys: id, name, type, status, start, end, duration, progress,
predecessors, baselineStart, baselineEnd, variance — every one sortable (except
predecessors), localised through labels, and usable by key without a ColumnDef:
options = {
panel: { columns: ['name', 'status', 'start', 'progress'] }, // the split panel
table: { columns: ['id', 'name', 'owner', 'status', 'start', 'end'] }, // the table view
};
// The 1.x panel: panel: { columns: ['name', 'start', 'end', 'duration', 'progress'] }
gantt.setColumnVisible('status', true, 'split'); // per view; omit the view for both
gantt.toggleColumnVisible('baselineStart', 'table'); // flips what the view shows now- Keys are looked up in your
columnsfirst, then in the built-ins, so lists mix both. - The built-in date, baseline-date and duration columns are 112, 124 and 96 px wide, or as wide
as their text needs in the chart's
localeandformats.dateFormat('D. MMMM YYYY'in German, say), so long dates are not cut. - Without lists, your
columnsbehave as before:tableOnlyones show only in the table view,visible: falseones nowhere; a listed key always shows. - A column with a built-in key inherits its label and sizes, and its cells and sort rule unless
you give it your own
render/cellTemplate. - Status (
statuscolumn):task.statuswhen set (a built-in value or any text), else derived —doneat 100% progress,overduepast the drawn end,inProgressonce started or with some progress, elsenotStarted; a milestone is due at the end of its day. It sorts by that order and shows a dot coloured by the theme keysstatusNotStarted,statusInProgress,statusOverdue,statusDone. Statuses follow the chart's clock (they update every minute). - Sorting reorders siblings at every level of the tree; names sort numerically ("Task 2" before
"Task 10"). A custom column sorts by
sortValue(row, ctx), else bytask[key]. render(row, ctx)and cell templates receive aColumnContext(now,taskById,endDateMode,collator…) for time- or data-dependent cells.
Three ways to define the columns of the task list panel.
a) Plain columns via the columns input — render returns a text string:
import { ColumnDef } from '@gantryjs/angular';
columns: ColumnDef[] = [
{ key: 'name', label: 'Task', width: 220 }, // built-in name cell
{ key: 'owner', label: 'Owner', width: 120,
render: r => (r.task.data?.['owner'] as string) ?? '' },
];b) Rich cell templates — for avatars, badges, progress bars, use cellTemplate
(from a viewChild TemplateRef):
<ng-template #statusCell let-row>
<span class="badge" [class]="row.task.data?.['status']">{{ row.task.data?.['status'] }}</span>
</ng-template>
<gantry [series]="tasks" [columns]="columns()" />statusTpl = viewChild<TemplateRef<ColumnCellContext>>('statusCell');
columns = computed<ColumnDef[]>(() => [
{ key: 'name', label: 'Initiative', width: 210 },
{ key: 'status', label: 'Status', width: 100, cellTemplate: this.statusTpl() },
]);c) Declarative ganttColumn inside <gantry> (import GanttColumnDefDirective):
<gantry [series]="tasks">
<ng-template ganttColumn="name" label="Initiative" [width]="210" />
<ng-container ganttColumn="owner" label="Owner" [width]="120">
<ng-template let-row>{{ row.task.data?.['owner'] }}</ng-template>
</ng-container>
</gantry>Its inputs are ganttColumn (the key), label, width, minWidth, maxWidth, visible,
sortable, sortValue and render, and for cell editing editable (also a bare attribute), editor,
editorOptions, exportValue, parse and validate; an <ng-template ganttCellEditor let-edit> is the column's
custom editor; a cell template goes inside an <ng-container ganttColumn>
(an <ng-template ganttColumn> can't hold one). The inputs you leave unset come from the column
with the same key: over one of your [columns] a definition overrides only what it sets (a new
cell keeps the column's sort rule); over a built-in column, a cell of your own sorts only with
sortable / sortValue.
<gantry> reads them through a content query, so a column inside @if or <ng-container> comes
and goes with it; a ganttColumn outside <gantry> is ignored.
The cell template context is { $implicit: row, row, ctx } where row: GanttFlatRow
(so row.task is your GanttTask) and ctx: ColumnContext (now, taskById, …).
<gantry [series]="tasks" [annotations]="annotations" showLegend />A legend strip under the chart (div.gantt-legend.gantt-legend--bottom; at the end of the
toolbar with legend.position: 'toolbar') lists the today marker (when showToday is on),
every annotation (with its colour and label), and the critical path / baseline when
those options are on. Each entry has a shape as well as a colour (line, dashed line, bar, strip).
It is not shown in the table view. Its strings are localisable (labels.legend, labels.today,
labels.annotation, labels.legendCritical, labels.legendBaseline). See
Toolbar, legend and markers.
The theme reaches the chart as --gantt-* custom properties in a [style] binding on the
<gantry> host: every theme key plus the derived tokens --gantt-bar-text-on-* and
--gantt-today-label. It is plain template output, so the server renders it too (no effect, no
flash). Change it through the theme API:
gantt.patchTheme({ barColor: '#6366F1', todayColor: '#F59E0B' }); // merged into the overrides
gantt.setTheme({ barColor: '#6366F1' }); // replaces the overrides
gantt.resetTheme(); // drops them
provideGantt({ darkMode: 'auto', theme: { barColor: '#6366F1' } }); // default for every chart belowEach layer overrides the one before: the light or dark built-in theme, provideGantt({ theme })
(merged along the injector hierarchy), options.theme, then setTheme() / patchTheme().
-
From the template. Bind a token on the element with a
[style]map or the--global--prefix: a template binding wins over the chart's host binding, and removing the value hands the token back to the theme. Angular 22 namespaces a plain[style.--gantt-bar]binding, which then loses to the host map. A stringstyleattribute (style="height: 480px") is kept.<gantry [series]="tasks" [style]="{ '--gantt-grid-line': gridColor }" /> <gantry [series]="tasks" [style.--global--gantt-today]="todayColor" />
-
From page CSS. The inline tokens beat a stylesheet rule on
gantry, so add!important(and set the matching contrast token when you change a fill):gantry { --gantt-bar: #6366f1 !important; --gantt-bar-text-on-bar: #ffffff !important; --gantt-today: #f59e0b !important; --gantt-font: 'Inter', system-ui, sans-serif !important; }
-
Paints. Bars, rows, bands and arrows bind their colours as CSS properties (
[style.fill]), so page CSS that targeted theirfill/strokeattributes no longer applies: restyle through the tokens (see Theming under Configuration). -
Dark mode.
[darkMode]="true | false | 'auto'"; unset,provideGantt({ darkMode })applies, else light, andoptions.darkModewins over both.'auto'followsprefers-color-schemeonce the chart runs in the browser (the server renders light). While the dark theme is on, the host has the classgantry-dark(andgantry-draggingwhile a bar is dragged or resized,gantry-loadingwhile a data-source page loads); there is nodata-themeattribute:gantry.gantry-dark { border-color: #334155; }
Built-in themes LightTheme / DarkTheme are exported. They meet WCAG 2.2 AA: bar labels reach
4.5:1 and bars, markers and arrows 3:1 against the background. Labels inside bars switch to white
or near-black when barTextColor is unreadable on a custom barColor (an explicit
task.textColor or bar.label.color is always kept); contrastRatio() and readableOn() are
exported from @gantryjs/core. Focus rings use --gantt-focus-ring (focusRing) and the light
milestones #7C3AED (see Theming). The 1.x colours are available as
LegacyLightTheme / LegacyDarkTheme. Full token map:
GanttTheme field |
CSS variable | GanttTheme field |
CSS variable | |
|---|---|---|---|---|
backgroundColor |
--gantt-bg |
arrowColor |
--gantt-arrow |
|
headerBackground |
--gantt-header-bg |
tooltipBg |
--gantt-tooltip-bg |
|
rowBg |
--gantt-row-bg |
tooltipBorder |
--gantt-tooltip-bdr |
|
rowBgAlt |
--gantt-row-bg-alt |
tooltipText |
--gantt-tooltip-txt |
|
barColor |
--gantt-bar |
selectionBg |
--gantt-sel-bg |
|
barTextColor |
--gantt-bar-text |
selectionBorder |
--gantt-sel-border |
|
barRadius |
--gantt-bar-radius |
scrollbarTrack |
--gantt-scrollbar-track |
|
milestoneColor |
--gantt-milestone |
scrollbarThumb |
--gantt-scrollbar-thumb |
|
baselineColor |
--gantt-baseline |
weekendBg |
--gantt-weekend-bg |
|
criticalColor |
--gantt-critical |
holidayBg |
--gantt-holiday-bg |
|
panelBg |
--gantt-panel-bg |
offHoursBg |
--gantt-off-hours-bg |
|
panelBorder |
--gantt-panel-border |
intensiveBg |
--gantt-intensive-bg |
|
borderColor |
--gantt-border |
statusNotStarted |
--gantt-status-not-started |
|
gridLine |
--gantt-grid-line |
statusInProgress |
--gantt-status-in-progress |
|
gridLineMajor |
--gantt-grid-line-major |
statusOverdue |
--gantt-status-overdue |
|
todayColor |
--gantt-today |
statusDone |
--gantt-status-done |
|
textPrimary |
--gantt-text |
progressOverlay |
--gantt-progress-overlay |
|
textSecondary |
--gantt-text-sec |
derived: label on a bar | --gantt-bar-text-on-bar |
|
textMuted |
--gantt-text-muted |
derived: label on a milestone | --gantt-bar-text-on-milestone |
|
fontFamily |
--gantt-font |
derived: label on a critical bar | --gantt-bar-text-on-critical |
|
fontSize |
--gantt-font-size |
derived: current-period label | --gantt-today-label |
|
focusRing |
--gantt-focus-ring |
rowHoverBg |
--gantt-row-hover-bg |
|
overallocatedColor |
--gantt-overallocated |
Locales, labels, format tokens and formats.readoutFormat are described once, for every binding, in
Dates, locales and format tokens. Set them per instance in
options, or as defaults with provideGantt():
<gantry
[series]="tasks"
[options]="{
locale: 'it', // month/day names → Italian
formats: { dateFormat: 'D MMM YYYY' }, // columns & tooltip dates
labels: { // any subset — rest stays English
loadMore: 'Carica altro', todayButton: 'Oggi',
viewModes: { Day: 'Giorno', Week: 'Settimana', Month: 'Mese', Quarter: 'Trimestre', Year: 'Anno' },
quarterPrefix: 'T', halfPrefix: 'S',
},
}" />Defaults for every chart below an injector level: provideGantt({ locale: 'it', labels: {…}, formats: {…} })
(merged per key along the hierarchy, see Providers & setup).
Nothing is required: a <gantry> without providers has the built-in bars, the English labels and
the light theme. The providers set defaults for every chart below the injector level they are
given at — bootstrapApplication, a route's providers or a component's providers — and all
return Provider[]:
| Provider | Use |
|---|---|
provideGantt(config?, ...features) |
Defaults (GanttGlobalConfig): theme, darkMode, locale, labels, formats. |
withBars(...defs) |
A provideGantt feature: custom bars at the same level, like provideGanttBar(...defs). |
provideGanttBar(...defs) |
Custom bar components ({ type, component }), or a replacement for a built-in type. |
new GanttDataSource(config) |
Not a provider: a lazy data source for [dataSource] (createGanttDataSource(config) is the same). |
// Application: defaults for every chart
bootstrapApplication(App, { providers: [provideGantt({ locale: 'it', darkMode: 'auto' })] });
// Route: one more label and a custom bar for the charts of this route
const routes: Routes = [
{
path: 'plan',
component: PlanPage,
providers: [provideGantt({ labels: { legend: 'Legenda' } }, withBars({ type: 'epic', component: EpicBar }))],
},
];
// Component: a theme tweak for the charts inside this component only
@Component({ providers: [provideGantt({ theme: { todayColor: '#0F766E' } })], … })- Each level merges its config over the level above:
theme,labels(withlabels.viewModes) andformats(withformats.header) per key,darkModeandlocalereplaced. So a component can change one label and keep the application's locale and theme. TwoprovideGanttat the same level: the last one wins. - A chart's own
options(and itsdarkModeinput) win over these defaults. - Bars resolve per chart: the built-in types first, then the
provideGanttBar/withBarsof each level from the outermost to the innermost (the nearest definition of a type wins).
| Input | Type | Default |
|---|---|---|
series (model) |
GanttTask[] |
[]: bind [(series)] to get the tasks the chart applied |
dataSource |
GanttDataSource | null |
null |
options |
GanttOptions |
{} |
annotations |
GanttAnnotation[] |
[] |
resources |
GanttResource[] |
[]: the people and teams of the resource view |
columns |
ColumnDef[] |
[] (options.columns wins) |
viewMode (model) |
ViewMode | string | undefined |
undefined: options.viewMode, else 'Month' |
darkMode |
boolean | 'auto' | undefined |
undefined: provideGantt({ darkMode }), else false (options.darkMode wins) |
showToolbar |
boolean |
true |
enableDrag / enableResize |
boolean |
true |
tooltipEnabled |
boolean |
true |
showLegend |
boolean |
false |
loading |
boolean |
false: the loading state while there are no rows |
errorMessage |
string | null | undefined |
null: a non-blank message shows the error state with Retry |
syncGroup |
GanttSyncGroup | null |
null: charts sharing a group share range, zoom, scroll and panel width |
| Output | Payload |
|---|---|
viewModeChange |
the new zoom level (ViewMode or a custom level name) |
taskDragged |
TaskDragEvent |
taskResized |
TaskResizeEvent |
selectionChange |
SelectionChangeEvent |
rowClick / rowDblClick |
GanttFlatRow (a double-click on an editable cell fires rowDblClick too) |
stateChange |
GanttState — panel, view, zoom, column widths, collapsed rows or sort changed |
loadError |
{ error, page } — a data-source page failed after its retries |
retry |
RetryEvent — Retry pressed on the error state: { source: 'error' | 'dataSource' } |
beforeChange |
GanttBeforeChangeEvent — $event.preventDefault() vetoes the change |
tasksChange |
GanttTasksChangeEvent — { tasks, change, applied } |
taskUpdated |
TaskUpdatedEvent — one per changed task |
taskProgressChanged |
TaskProgressEvent |
dependencyCreated / dependencyDeleted |
DependencyChangeEvent |
historyChange |
GanttHistoryState |
beforeExport |
GanttBeforeExportEvent — $event.preventDefault() cancels a download() / print() |
export |
GanttExportEvent — { format, trigger, fileName, size, pages } |
- Boolean inputs take attribute values:
<gantry showLegend>istrue,showToolbar="false"isfalse. viewModeis a model.[(viewMode)]="zoom"keeps a signal (or a field) in sync with the zoom level.viewModeChangereports the changes made by the toolbar, the+/-keys,zoomIn()/zoomOut()/setViewMode(), a restored state or a newoptions.viewMode; it does not fire for a value the parent sets through[viewMode], nor on the first render. Between[viewMode]andoptions.viewMode, the one that changed last wins.seriesis a model.[(series)]="tasks"keeps the tasks you pass in sync with the edits the chart applies (drags, cells, links, undo…);(tasksChange)reports each one. See Editing for the events and[series]withediting.controlled.syncGrouptakes acreateGanttSyncGroup()group (see Synchronised charts). A chart leaves its group when it is destroyed or the input changes.loading/errorMessage/retrydrive the chart's loading, error and empty states; the empty state shows your[ganttEmpty]content (see ng-content slots).
@gantryjs/angular exports everything in @gantryjs/core (types such as GanttTask, GanttOptions and
ViewMode, the themes, the labels and the date, tree, critical-path and theme helpers) and these
Angular pieces:
| Export | What |
|---|---|
GantryComponent |
<gantry> |
GanttColumnDefDirective |
ganttColumn column definitions |
GanttCellEditorDirective, CellEditorTemplateContext |
<ng-template ganttCellEditor> cell editors |
GanttTooltipTemplateDirective, GanttTooltipTemplateContext |
<ng-template ganttTooltip> tooltip content |
provideGantt, withBars, GanttGlobalConfig, GanttFeature |
optional defaults (Providers & setup) |
provideGanttBar, GanttBarDef, GanttBarBase |
custom bars |
GanttTaskBarComponent, GanttMilestoneComponent, GanttGroupBarComponent |
the built-in bars, reusable for another barType |
GanttDataSource, createGanttDataSource, GanttDataSourceConfig |
lazy loading |
ColumnDef |
ColumnDef<TemplateRef<ColumnCellContext>, TemplateRef<CellEditorTemplateContext> | ((ctx) => HTMLElement)> |
GanttDownloadOptions |
the options of download(format, options): that format's options and fileName |
The services, the inner components (canvas, header, task list, toolbar, tooltip, load-more footer) and the keyboard directive are internal: drive the chart through its inputs, outputs and methods.
// Component methods (access via viewChild(GantryComponent))
gantt.zoomIn() / zoomOut()
gantt.setViewMode(ViewMode.Week) // reported through viewModeChange
gantt.fitToScreen() // the finest level that shows every task, then scroll to the first
gantt.view() // 'split' | 'table' | 'chart' (read-only signal)
gantt.setView('table') / togglePanel()
gantt.scrollToToday() / scrollToDate(date) / scrollToTask(id) // optional ScrollBehavior
gantt.getBarVisual(id): BarVisual | undefined // geometry and paints, also for rows out of view
gantt.criticalIds() // ReadonlySet<string>: the tasks on the critical path (signal)
gantt.getSelectedTasks(): GanttTask[]
gantt.setSelectedTasks(ids) / clearSelection() / selectAll()
gantt.updateTask(id, patch) / appendTasks(tasks) // silent writes: no events, no history
gantt.undo() / redo(): boolean // editing history; gantt.canUndo() / canRedo() are signals
gantt.clearHistory()
gantt.addDependency(fromId, toId, type?, lag?) / removeDependency(fromId, toId, type?): boolean
gantt.editCell(taskId, columnKey): boolean // open a cell editor (editing.cells)
gantt.focusTask(id, { area?: 'bar' | 'row' }): boolean
gantt.collapseAll() / expandAll()
gantt.setColumnVisible(key, visible, view?) / toggleColumnVisible(key, view?)
gantt.setTheme(override) / patchTheme(patch) / resetTheme()
gantt.getState(): GanttState / setState(state, emit = true)
await gantt.exportSvg(options?) / exportCsv(options?) // Promise<string>; exportPng(options?): Promise<Blob>
await gantt.download('svg' | 'png' | 'csv', options?) // Promise<boolean>: false when (beforeExport) vetoed it
await gantt.print(options?) // Promise<boolean>: every row, on as many pages as it takes@gantryjs/angular is SSR-safe. It reads no browser globals (DOCUMENT and Renderer2 instead of
window / document), and everything browser-only — the ResizeObserver, the load-more
IntersectionObserver, the one-minute clock, the scroll listeners, matchMedia for
darkMode: 'auto', the tooltip popover — starts after the first browser render
(afterNextRender) and stops when the chart is destroyed (DestroyRef). The server HTML already
has the rows, the bars, the theme tokens and the region role; with darkMode: 'auto' it is
light until the browser reports the colour scheme. exportSvg() and exportCsv() work during server rendering too
(custom bars drawn as built-in); exportPng(), download() and print() reject there, before beforeExport (see
On the server and in Node).
<gantry [series]="tasks" [loading]="pending()">
<!-- Toolbar extras: between Today and the view switch -->
<button ganttToolbar>Export CSV</button>
<!-- Task list footer -->
<div tlFooter>{{ tasks.length }} total tasks</div>
<!-- Empty state (no tasks and nothing loading); default: labels.emptyState -->
<div ganttEmpty>No tasks yet. <button (click)="add()">Add one</button></div>
<!-- Tooltip content (import GanttTooltipTemplateDirective) -->
<ng-template ganttTooltip let-task="task">{{ task.name }}</ng-template>
<!-- Below the gantt -->
<div ganttFooter>© 2026 My Company</div>
</gantry>[ganttToolbar]controls are tab stops of their own, outside the toolbar's arrow-key set.[ganttEmpty]content wrapped in@iffills the slot even while the condition is false (Angular content projection), and the defaultlabels.emptyStatetext then doesn't show: put the condition inside the element, or setlabels.emptyStateinstead.
| 1.x | 2.0 |
|---|---|
npm i ngx-gantry, import { … } from 'ngx-gantry' |
npm i @gantryjs/angular, import { … } from '@gantryjs/angular' |
provideGanttLib(config), GanttModule.forRoot(config) |
provideGantt(config) — optional; the built-in bars need no provider |
provideGanttBar(def) in bootstrapApplication |
provideGanttBar(...defs) at application, route or component level, or provideGantt(config, withBars(...defs)) |
inject(GanttBarRegistry).register(def) |
provideGanttBar(def) in the providers of the page or route |
custom bar with its own ctx = input.required() and get context() |
extends GanttBarBase (it declares ctx) |
options.viewMode written back from (viewModeChange) |
[(viewMode)]="zoom" |
gantry[data-theme='dark'] |
gantry.gantry-dark |
body > .gantt-tooltip, .gantt-tooltip-popover |
gantry .gantt-tooltip (dl.gantt-tooltip-rows > div.gantt-tooltip-row > dt + dd) |
.gantt-panel-scroll · .gantt-timeline-scroll · .gantt-timeline-header |
.gantry-panel · .gantry-canvas-viewport · .gantry-canvas-header-sticky |
<gantry [style.--gantt-bar]="c"> |
[style]="{ '--gantt-bar': c }" or [style.--global--gantt-bar]="c" |
import { GanttLayoutService, … } from 'ngx-gantry' |
internal: use <gantry>'s inputs, outputs and methods |
<gantry [visibleRows]>, GanttTooltipDirective, GanttModule |
removed |
Every change is listed in the changelog.
apps/demo-angular is the Gantry site, built with @gantryjs/angular:
- Home — what Gantry is, with live charts;
- Playground — every option at hand: the code follows your choices in the syntax of each
framework and the link keeps the setup (
?setup=); - Examples — a product roadmap (custom columns, locales), a project board, lazy loading with a data source and custom bar components, each with its usage code;
- Theming — the
--gantt-*custom properties with presets, a contrast check and the code; - Frameworks — install and use it as a Web Component or in Angular, React, Vue and Svelte;
- API — the
<gantry-element>properties, methods, events, CSS custom properties, parts and states, generated from the Custom Elements Manifest.
The Web Component playground (apps/demo-vanilla) and the React, Vue and Svelte demos share the
site's look through the private apps/demo-kit package: design tokens and components
(site.css, gs- classes only, so no page style reaches a chart), the light/dark switch, links,
icons, the playground model (PLAYGROUND_SECTIONS, read by both playgrounds) and the API model.
pnpm start # dev server (ng serve gantry-demo)
pnpm build:demo # static build → dist/gantry-demo (relative base href)Deploy dist/gantry-demo/browser to any static host (GitHub Pages, Netlify,
Vercel — vercel.json builds and deploys it). The site uses path routes: add an SPA fallback
(404.html = index.html, or a _redirects / rewrite rule) so a deep link such as /playground
loads on refresh.
A pnpm workspace (pnpm-workspace.yaml):
| Path | What |
|---|---|
packages/core |
@gantryjs/core — framework-free logic, shared by every binding |
packages/vanilla |
@gantryjs/vanilla — the <gantry-element> Web Component |
packages/react, packages/vue, packages/svelte |
thin framework bindings over the Web Component |
packages/angular |
@gantryjs/angular — the Angular library (imports @gantryjs/core) |
apps/demo-angular |
the Gantry site (Angular showcase) |
apps/demo-* |
one Vite demo per framework |
apps/demo-kit |
the demos' shared look, theme switch and playground model |
| Script | Effect |
|---|---|
pnpm dev:angular / dev:vanilla / dev:react / dev:vue / dev:svelte |
run a demo (packages resolve from source, so library edits hot-reload); dev:vanilla is the options playground |
pnpm build |
build all packages, the Angular library, the showcase and the demos |
pnpm test |
Vitest: core (Node) + Web Component (happy-dom) |
pnpm typecheck |
type-check every package |
pnpm changeset |
describe a change for the next release (see .changeset/README.md) |
pnpm test:tz |
core tests in other time zones (Los Angeles, Kolkata) |
pnpm e2e · pnpm e2e:full |
Playwright end-to-end tests on every binding (after pnpm build; e2e:full builds first) |
pnpm e2e:visual · pnpm e2e:update |
compare / regenerate the screenshot baselines (e2e/__screenshots__) in the Playwright container (Docker/Podman) — same pixels as CI |
Workspace packages expose a @gantryjs/source export condition pointing at
their TypeScript sources; the demos, tests and type-checks use it, so nothing
has to be built first. Published consumers never see that condition.
The repo ships a shared Claude Code setup:
| Path | What |
|---|---|
CLAUDE.md |
architecture, commands and hard rules (no auto-commits, render model in core, date rules) |
.claude/rules/ |
per-area rules loaded when files in that area are touched (core, Web Component, Angular, bindings, docs) |
.claude/skills/ |
/gantry-feature (add an option end to end), /gantry-visual-qa (browser checks), /gantry-release (manual), plus vetted external skills: angular-developer, accessibility, svelte-core-bestpractices, vue-best-practices, web-design-guidelines (sources in skills-lock.json) |
.claude/agents/ |
gantry-reviewer (parity & invariants review), gantry-qa (browser QA), gantry-docs-sync (docs, changeset, playground) |
.claude/settings.json |
safe commands pre-approved; commit / push / tag / publish always ask; unit tests of the edited package run after each edit (GANTRY_HOOK_TESTS=0 to skip) |
The browser QA works without Claude too: pnpm build && pnpm e2e runs the Playwright suite
in e2e/ on the Web Component, React, Vue, Svelte and Angular demos (console errors,
header/row alignment, scroll sync, drag, panel collapse, table view, binding parity) with a
frozen clock; screenshots for review land in test-results/e2e/shots/.
Vitest runs two projects: the framework-free core (render model, timeline math, formatter, CPM, interaction
and state helpers) in plain Node, and the <gantry-element> Web Component in happy-dom. ng test runs the
@gantryjs/angular specs (Vitest + happy-dom, zoneless TestBed), including a server-render smoke test.
pnpm test # core + Web Component, then @gantryjs/angular
pnpm test:watch # Vitest watch mode
pnpm test:angular # @gantryjs/angular only
pnpm test:tz # core in other time zonesSpecs live in packages/core/src/__tests__/, packages/vanilla/src/__tests__/ and
packages/angular/src/**/*.spec.ts; the browser suites (paint parity, accessibility, CSP, lifecycle, performance,
screenshots) are in e2e/.
The showcase (gantry-demo) and the library are built with the esbuild-based
@angular/build builders (application, dev-server, ng-packagr); there is no
webpack in the toolchain. The live showcase replaces a component workshop such as Storybook.
| Package | License | Size |
|---|---|---|
@gantryjs/core |
MIT | shared logic from this repo |
@gantryjs/core itself has no runtime dependencies (dates are formatted with Intl).
Zero zone.js required. Zero @angular/animations. Zero CDK (tooltip uses native Popover API).
Recent Releases (Click to expand)
- One chart for every framework: a new
<gantry-element>Web Component engine, wrappers for React, Vue and Svelte, and a native Angular 22 package, now@gantryjs/angular(formerlyngx-gantry), all driven by the same@gantryjs/coremodel. - Editing: drag, resize, progress, links, editable cells, undo/redo and a cancelable
before-change, in every binding. - Working calendar: durations, snaps, critical path and rollups in working days or hours.
- Resource view: daily load per person and team against its capacity, with over-allocation highlighted.
- Export and print: SVG, PNG, CSV and paginated print, re-rendered from the model.
- Accessible by default: WCAG 2.2 AA palette, keyboard and touch paths, forced colors; 35 examples in all five frameworks.
- Scroll Tearing & Ping-Pong: Resolved visual bar duplication during vertical scrolling by replacing fragile microtask lock with a robust scroll-source timeout lock.
- Vertical Scroll Alignment: Synchronized task list and SVG canvas vertical scroll positions 1:1 without header height distortion.
- Today Marker: Removed top vertical line from header to avoid obscuring date labels, and updated positioning to reflect exact time-of-day.
- Hit Testing: Fixed
_geoFromPointpointer event row lookup for virtual scrolled rows. - Group Bar Defaults: Ensured
TaskType.Groupresolves to'group'bar type by default.
- Initial Public Release: Zoneless, signal-based Angular 20+ Gantt chart.
For complete release history, view the full CHANGELOG.md.

