Gantt Chart
ProPortfolio roadmaps with command bus, undo/redo, drag & drop, dependency engine, a computed critical path (real forward/backward pass, total float in days, all four link types with lag), baselines, resource leveling, MS Project XML import/export and row virtualization.
Complexity tier: Application The interactive bar, plus a stated data-volume ceiling, a persistence contract, a bundle budget and an accessible parity surface.
import { SvGanttChartComponent } from '@styloviz-pro/gantt';Demo & usage
Basic usage
ProUsage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Critical path + dependencies
ProLag on a link
ProA link carries the gap, so it follows when the predecessor slips. Typing the gap into the successor's dates instead looks identical on the chart and does not.
Read the float, not just the flag
Pro`taskFloat()` is how many days each task can slip before the project finish moves — the question a planner asks far more often than "is this critical".
Surface a dependency cycle
ProTwo tasks each waiting for the other can never be scheduled. They are named rather than quietly drawn.
Resource view with baselines
ProPersist edits & track selection
ProWhen to use
- Planning and tracking project schedules with dependencies and resourcing.
- You need critical path, baselines, or resource leveling on a roadmap.
- For a simple status board without dates, use the Kanban Board.
Async states
- loading
- empty
- error
- retry
Item identity & owned state
- Restorable state
- collapsedTaskIds
- pendingTaskIds
- resourceViewCollapsedIds
Bind these to put a saved view back — the open sections, the hidden columns, the selected rows — and read their
Changeevents to store what the user does.
Saved state
- Stored kinds
- sv-gantt-chart/hierarchy
- sv-gantt-chart/project
- sv-gantt-chart/split-view
What you will find under your own storage key. You choose the key; the blob names its own
kindand schemaversion, so a component never reads state another one wrote.- Reading it back
- Restore through the component's own
restore…helper rather than casting the parsed JSON. A blob from an older release is migrated where it can be and refused with a reason where it cannot — so an upgrade never silently applies last month's shape to this month's fields.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| tasks | GanttChartTask[] | [] | Tasks to schedule (id, name, dates, progress, dependencies…). |
| resources | GanttChartResource[] | [] | Resources that tasks can be assigned to. |
| assignments | GanttChartAssignment[] | [] | Task↔resource assignments (for resource view / leveling). |
| markers | GanttChartMarker[] | [] | Vertical marker lines (milestones, deadlines, releases). |
| insights | GanttChartInsight[] | [] | Insight items shown in the insight rail. |
| scale | GanttChartScale | 'week' | Timeline granularity: day, week, month… |
| maxTimelineColumns | number | 480 | The most time columns the timeline will draw. Past this, `scale` coarsens a step at a time — day → week → month → quarter — until it fits. THIS IS THE RENDER-SIDE SCALE LIMIT, AND IT IS A CLIFF, NOT A SLOPE. The `columns` computed itself is cheap (0.1ms for 3661 columns); what costs is that every column becomes two DOM elements, in the header and in the grid backdrop, and that cost measures superlinear: on this machine a ten-year plan left at `day` took 3661 columns / 14851 nodes / 1.4s to render, and a forty-year plan took 14619 columns / 58683 nodes / 29.5s — four times the columns for twenty-one times the time. Rows are virtualised so the task axis is bounded; before this input the TIME axis was not, and `scale` is exactly the kind of input a host sets once to `'day'` and never revisits when the plan later grows to span years. The default is deliberately generous — 480 columns is over a year of days, or nine years of weeks — so ordinary plans never notice it. Raise it if you are rendering to paper or to a very wide screen and would rather have the columns than the frame rate; lower it for embedded or mobile use. |
| density | GanttChartDensity | 'comfortable' | Row density. |
| variant | GanttChartVariant | 'default' | Visual variant. |
| showSidebar | boolean | true | Show the left task sidebar/grid. |
| showDependencies | boolean | true | Draw dependency links between tasks. |
| showBaselines | boolean | false | Show baseline bars beneath tasks. |
| showMarkers | boolean | true | Show vertical markers. |
| showToday | boolean | true | Highlight today's date line. |
| showInsightRail | boolean | false | Show the insight rail. |
| showResourceSummary | boolean | false | Show the resource summary panel. |
| showGroupColumn | boolean | true | Show the group column in the sidebar. |
| highlightCritical | boolean | false | Highlight the critical path. |
| highlightOverallocated | boolean | false | Highlight over-allocated resources. |
| compactMeta | boolean | false | Use a compact meta row. |
| showLabels | boolean | true | Show labels on task bars. |
| scheduleMode | 'auto' | 'manual' | 'custom' | 'auto' | Scheduling mode: auto, manual or custom. |
| resourceView | boolean | false | Switch to the resource-centric view. |
| showFilterBar | boolean | false | Show the filter bar above the chart. |
| toolbarScrollable | boolean | false | Make the toolbar horizontally scrollable. |
| filterPlaceholder | string | 'Filter tasks...' | Placeholder for the filter input. |
| ariaLabel | string | 'Gantt chart' | Accessible label for the chart. |
| emptyMessage | string | 'No tasks are available for this planni… | Message shown when there are no tasks. |
| featureFlags | Partial<GanttFeatureFlags> | {} | Toggle optional advanced features on/off. |
| showTaskModeColumn | boolean | false | Show the task scheduling-mode column. |
| visibleColumns | string[] | ['name', 'startDate', 'duration', 'prog… | Which sidebar columns to display and in what order. |
| workWeek | number[] | [1, 2, 3, 4, 5] | Working weekdays (0=Sun … 6=Sat). |
| holidays | GanttChartHoliday[] | [] | Non-working holiday dates excluded from scheduling. |
| gridLines | 'both' | 'horizontal' | 'vertical' | 'none' | 'both' | Grid line style: both, horizontal, vertical or none. |
| loading | boolean | false | True while the schedule is being fetched. Replaces the chart with a skeleton. |
| isLoading | boolean | false | Deprecated alias of `loading`: either one replaces the chart with a skeleton. |
| error | SvDataError | string | null | null | Load failure. Use `toSvDataError()` from `@styloviz/core` to normalize an `HttpErrorResponse`. Replaces the chart with a retry prompt. |
| pendingTaskIds | readonly (string | number)[] | ReadonlySet<string | number> | [] | Ids of tasks with a write in flight. Their bars dim and cannot be dragged or resized, so a second edit cannot race the first and leave `rollback()` ambiguous. |
| windowStart | Date | string | number | null | null | Pin the visible time window instead of letting the chart size itself to the data. `null` on either side keeps the derived behaviour for that edge. This matters for a server-backed chart: without it, one task starting in 2027 stretches the timeline across three years, and fetching by the derived range feeds itself — new tasks widen the range, which fetches more tasks. Set the window to what you actually queried and the loop cannot form. |
| windowEnd | Date | string | number | null | null | End of the pinned visible window. See `windowStart`. |
| undoRedoStepsCount | number | 10 | Number of undo/redo steps to retain. |
| bodyScrollHeight | number | 520 | Height of the scrollable body area in px. When total row height exceeds this, virtual scrolling activates. Default: 520. (GANTT-090) |
| taskDialogFields | GanttTaskDialogFields | {} | Controls which optional form fields are shown in the Add/Edit Task dialog. All fields default to visible; set a key to `false` to hide it. Example: `[taskDialogFields]="{ lane: false, tags: false }"` |
| collapsedTaskIds | Set<GanttChartTask['id']> (two-way) | new Set() | Which parent tasks are collapsed (two-way). A `model` rather than internal state so a host can open the branch a deep link names, or restore a view it saved server-side rather than in this browser. The chart still hydrates it from `localStorage`, but only while it is empty — see the hierarchy effect in the constructor. Whoever binds it wins over storage, because a host that troubled to supply a value has a better claim than a value left behind on this one device. |
| resourceViewCollapsedIds | Set<string> (two-way) | new Set() | Which resource-view groups are collapsed (two-way). |
Outputs
| Event | Payload type | Description |
|---|---|---|
| tasksChange | GanttChartTask[] | Emitted whenever the working task set changes through user interaction (add / edit / delete / indent / move / bar drag / undo / redo / reset). Payload is the full, current task list — persist it to your backend. |
| selectionChange | GanttChartTask | null | Emitted when the selected task changes. Payload is the selected task, or `null` when the selection is cleared. |
| tasksChangeRequest | GanttTasksChangeEvent | The same change as `tasksChange`, with a way to undo it. Use this one when the edit has to be persisted: the chart has already applied it optimistically (anything else makes dragging a bar feel broken), and `rollback()` puts the chart back if the write fails. It rides the existing undo history, so the restore is exact. ```ts onEdit(e: GanttTasksChangeEvent): void { this.api.saveTasks(e.tasks).subscribe({ error: => { e.rollback(); this.toast.error('Could not save that change.'); }, }); } ``` Refused, with a dev-mode warning, once a later edit has been made — undoing then would discard that newer edit too. Bind `pendingTaskIds` so a task cannot be edited again while its write is in flight. |
| rangeChange | GanttDateRange | The visible time window changed — fetch the tasks that fall inside it. Guarded so the same range never fires twice. **Set `windowStart` / `windowEnd` from what you fetch**: without a pinned window the range is derived from the tasks, so newly-arrived tasks widen it and trigger another fetch. |
| retry | void | The host should re-run the failed load. |
| collapsedTaskIdsChange | Set<GanttChartTask['id']> | Emits the new `collapsedTaskIds` whenever it changes — the output half of `[(collapsedTaskIds)]`. |
| resourceViewCollapsedIdsChange | Set<string> | Emits the new `resourceViewCollapsedIds` whenever it changes — the output half of `[(resourceViewCollapsedIds)]`. |
Wire it to your API
This component is headless about transport: it renders what you give it and reports what the user asks for. Nothing below requires @styloviz/data — drive it from whatever you already use.
Template
ProBound to the names this component actually exposes.
Host class
ProDrive the three async inputs from your own fetch.
- This component does not emit a query, so it renders what you give it. Fetch on your own trigger and bind `tasks`, `loading` and `error`.
- Normalize failures with `toSvDataError()` from `@styloviz/core`. It derives `retryable` from the status, so a 404 does not get a "Try again" button that can only fail again.
Full walkthrough: Connecting to a backend.
Customization & theming
- Configurable columns, holidays and work week.
- **The scheduling invariants, as guarantees rather than current behaviour.** Links: `fs` starts the successor no earlier than the predecessor finishes, `ss` no earlier than it starts, `ff` finishes no earlier than it finishes, `sf` finishes no earlier than it starts — each plus `lag`, in whole days, negative for a lead, defaulting to `fs` when no type is given. Every one is a floor and never a ceiling: a task is never pulled earlier than the date you gave it, so hand-typed dates survive. `sf` is the one that surprises — "may not finish before the other starts" is satisfied by being early, so an `sf` successor can legitimately land entirely before its predecessor.
- **Pinned, unschedulable and derived.** A `scheduleMode: 'manual'` task keeps its dates whatever its predecessors require, and still constrains its own successors. A task in a cycle keeps the dates it was given, is named in `scheduleCycles()`, and is never critical — a plan that cannot be executed has no critical path. A dependency naming an absent task is ignored and reported. `projectFinish` is the latest early finish; `totalFloat` is late start minus early start in whole days; a task is critical exactly when its float is zero and it is not in a cycle. Spans are whole calendar days, so a DST transition inside one changes nothing.
- **Resource leveling.** Only ever moves a task later, never earlier; preserves every duration in calendar days; leaves no two tasks of one assignee sharing a day, including the handover day; is idempotent, so a second pass moves nothing; touches only leaf tasks that have an assignee; and costs one undoable command however many tasks move.
- **MS Project XML fidelity, field by field.** Carried unchanged: title, start, end, progress, `scheduleMode`, `baselineStart` / `baselineEnd`, assignee, group, lane, priority, status, tags, summary, colour, WBS, split `segments`, and all four `constraints` bounds. Carried in meaning but re-keyed: `id`, `parentId`, `children` (flattened to `parentId`) and `dependencies[].taskId`, because MSPDI identifies tasks by integer `UID`. Not carried, deliberately: `ownerId` (re-derived from the file's assignments, so writing it too would give one fact two sources of truth), `reallocated` (computed by the levelling pass, never authored) and `milestone: false` (absent and false say the same thing).
- **Where the fidelity comes from, and what MS Project itself will honour.** Link `lag` is written as MSPDI `LinkLag` in days, and baselines and `scheduleMode` as `<Baseline>` and `<Manual>` — all native fields, so MS Project schedules against them. The fields MS Project has no column for — group, colour, lane, status, tags, `segments` and `constraints` — travel in `<Notes>` as bracketed markers, so this kit round-trips them while MS Project shows them as text. `constraints` is the one place its model is genuinely narrower: it holds one constraint per task where this kit holds up to four bounds, so its own scheduler will not enforce them on a file it opens.
- An imported colour is validated before it is used: `color` reaches the DOM as a class list, so a file may only name colour utilities (`bg-`, `text-`, `border-`, `ring-`, `from-`, `via-`, `to-`, optionally `dark:`-qualified) and only a few of them. A file that tries to hand a bar layout classes gets the default colour instead of painting over your page. One consequence for your own data: an arbitrary-value class such as `bg-[#ff0000]` does not survive a round trip — brackets are what delimits a marker — so use palette utilities on `color` if the plan will be exported.
- `maxTimelineColumns` (default 480) caps the time axis. Rows are virtualized on height; columns are bounded by coarsening the scale, so a plan whose span grows over time cannot silently turn into thousands of columns. Raise it when rendering to paper or a very wide screen, lower it for embedded and mobile hosts.
- Scheduling modes, baselines and dependency links, each with optional `lag` in days (negative for a lead).
- A real critical path method: forward and backward pass, `taskFloat()` in whole days, `scheduleCycles()` for unschedulable data, and all four link types honoured in both directions. Manually scheduled tasks stay put and still constrain what follows them.
- `tasksChangeRequest` (with `rollback()`), `rangeChange`, `loading` / `error` / `retry` and `pendingTaskIds` make it wireable to a real planner API; `tasksChange` remains for hosts that only persist. `isLoading` is deprecated in favour of `loading`.
Best practices
- Do: Define `workWeek` and `holidays` so durations reflect real working time.
- Do: Persist edits by handling `tasksChangeRequest` — same payload as `tasksChange` plus a `rollback()` that undoes the optimistic edit when the write fails.
- Do: Pin `windowStart` / `windowEnd` to the span you fetched. Left unpinned, the timeline sizes itself to the data, so one task in 2027 stretches the chart across three years — and fetching by `rangeChange` then feeds itself.
- Do: Bind `pendingTaskIds` while a save is in flight: those bars dim and stop responding to drag, so a second edit cannot race the first.
- Tip: Turn on `highlightCritical` to make the schedule's risk path obvious — but read `taskFloat()` for the rest of the plan, because "not critical" spans everything from one spare day to three spare months.
- Do: Put the gap on the link as `lag`, never into the successor's dates. A hand-typed gap does not move when the predecessor slips, which is the one moment it needed to.
- Do: Check `scheduleCycles()` on data you did not author. A cycle is a plan that cannot be executed, and it is invisible on the chart.
- Don't: Don't expect working-day scheduling from the critical path: it counts calendar days, because the bars it is drawn over do. `workWeek` and `holidays` shade the timeline, they do not compress durations.
- Tip: Scale limits, measured rather than claimed. **Up to ~1,000 tasks** is comfortable at any span: ~8,500 DOM nodes, and a full recompute after an edit under 50ms. **1,000–5,000 tasks** still works but wants the advice below — 5,000 tasks is ~40,000 DOM nodes and a first render measured in seconds. **Past ~5,000 visible tasks, split the plan** into portfolio and project charts rather than tuning one. Dependencies are effectively free: the critical path is O(tasks + links), so a 5,000-link chain costs ~36ms.
- Do: Cut the number of *visible* rows, because that is the number the cost actually follows — not the number of tasks you own. Pinning `windowStart` / `windowEnd` and collapsing parents both work; a 5,000-task plan showing 300 rows behaves like a 300-task plan.
- Tip: Bars are row-virtualized automatically past `bodyScrollHeight`, so the timeline's cost is flat in the task count. What is not virtualized is the accessibility table — one row per visible row, 7 DOM nodes each, 86% of the DOM at 5,000 tasks. That is deliberate: it is the whole chart for anyone who cannot see the bars, and a windowed table would be a partial one.
- Do: Leave `maxTimelineColumns` (default 480) alone unless you know you want the columns. A ten-year plan left at `scale="day"` would be 3,661 columns and ~1.4s of render, so the scale coarsens a step at a time — day → week → month → quarter — until it fits, and says so in the notice strip if it clamps a zoom you asked for.
- Tip: Resource leveling moves an unbounded number of tasks but costs one command, one recompute and one undo entry: 400 tasks on one assignee levels in ~140ms, and a single Undo puts all 399 moved tasks back.
- Don't: Don't treat task ids as stable across an MS Project XML round trip. MSPDI identifies tasks by integer `UID`, so ids are re-minted on import — `parentId` and every `dependencies[].taskId` are rewritten to match, so the graph survives, but a `taskId` you stored elsewhere will not resolve.
- Tip: A plan can go out to MS Project and come back without losing a field, including the ones MS Project has no column for — see the fidelity contract below for the exact list. If you import files from elsewhere, note that a `lag` given in hours is rounded to the nearest day, and a segment or constraint with an unparseable date is dropped rather than drawn.
Accessibility
- A bar's position IS its dates, so the chart publishes a table alongside it — task, start, end, progress, status and assignee, one row per visible row and in the same order. Nothing else on the page carries those dates. It mirrors what is drawn: collapsed children are absent, a nested chart gains a Parent column so the hierarchy is not left to the indent, and a milestone reads "Milestone" rather than a second identical date.
- A one-line overview above the table gives the row count, the span the schedule covers and how many rows are critical or at risk — the shape of the picture, which the rows can only imply.
- A resource-view header names its own task count and reads its number as "% utilised", because that column means utilisation there and a listener has no indent to tell them the row is a grouping.
- The chart is a focusable region with a labelled toolbar; toolbar actions expose disabled state via `aria-disabled`.
- Keyboard task selection and movement; command notices use polite live regions and loading sets `aria-busy`.
- Row headers and dates are screen-reader friendly.
Feedback on Gantt Chart
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.