The timeline
Scales, range, zoom, markers, and non-working days
Your tasks run from March to November. The chart has to pick how wide one day is on screen. It also has to decide where the first cell starts and where the last one ends. The timeline is the layer that answers both questions.
The six scales
A scale is one row in a fixed table. The row decides what each header cell says, how much time one tick covers, and how many pixels one drag step is worth. There are six scales, and the table is a module constant.
GanttScaleKey is 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year'. The chart starts on month.
| scale | labelUnit | tickUnit | unitPerTick | dragStepUnit | dragStepAmount | basePxPerDragStep |
|---|---|---|---|---|---|---|
hour | day | hour | 1 | minute | 15 | 30 |
day | day | hour | 1 | hour | 1 | 32 |
week | month | day | 1 | hour | 6 | 54 |
month | month | day | 1 | day | 1 | 32 |
quarter | quarter | month | 1 | day | 3 | 24 |
year | year | month | 1 | day | 7 | 28 |
unitPerTick is 1 on every built-in scale. dragStepUnit and dragStepAmount are also the snap grid a bar drag lands on, described in Editing tasks. The label formatters attached to each scale can be replaced per scale, covered in Locale and date formats. The type itself is in Scales.
Tick width is derived, not stored
No scale carries a pixel width. Each tick is measured when the cells are built:
// src/utils/timeline.ts, createBottomRowCells
const dragStepRatio = basePxPerDragStep / dragStepAmount;
const nextTick = current.add(unitPerTick, tickUnit);
const tickDuration = nextTick.diff(current, dragStepUnit);
const widthPx = tickDuration * dragStepRatio;That gives these real numbers:
| scale | px per drag-step unit | one tick covers | tick width | px per day |
|---|---|---|---|---|
hour | 2 px/min | 60 min | 120px | 2880 |
day | 32 px/h | 60 min | 32px | 768 |
week | 9 px/h | 24 h | 216px | 216 |
month | 32 px/day | 1 day | 32px | 32 |
quarter | 8 px/day | 1 calendar month | 224–248px | 8 |
year | 4 px/day | 1 calendar month | 112–124px | 4 |
The quarter and year scales use a month tick, so their cells are as wide as the real calendar month. February 2025 is 224px at quarter scale, a 31-day month is 248px.
Where the starting scale comes from
defaultScale is a seed. It is read once on mount, and prop changes after that are ignored. Whatever scale the chart lands on is written to sessionStorage under the key gantt-scale — a user's pick, and the seed itself on mount when it is not month. That stored value wins on the next mount. Give each chart on a page its own storageKey if they should not share one scale.
The rendered range
With no range props, the chart fits the timeline to the data. It scans every task for the minimum of startDate and baselineStart, and the maximum of endDate and baselineEnd. Baselines count, so a baseline hanging outside its bar is not clipped. Roll-up runs first, so a summary row widened by its children widens the range too.
Then it pads. The buffer is five ticks of the current scale on each side:
| scale | buffer per side |
|---|---|
hour | 5 hours |
day | 5 hours |
week | 5 days |
month | 5 days |
quarter | 5 months |
year | 5 months |
Cells are laid out from that padded start, snapped down to a tick boundary. The loop runs while the tick start is before the padded end, so the final cell can finish past it.
A task of 2025-01-10 → 2025-01-12 at month scale pads to 2025-01-05 … 2025-01-17. That is 12 cells, 2025-01-05 through 2025-01-16, and the bar sits 160px from the left.
Pinning the window with visibleStart and visibleEnd
visibleStart and visibleEnd are ISO strings. A pinned end is used verbatim. No task fitting happens on that side, and no buffer is added.
Pin both ends and the task dates stop mattering for layout. A task falling outside the window still gets a row, but its bar collapses to a 1px sliver against the nearest edge. Pin one end and the other still auto-fits with its five-tick buffer.
The pinned start is still snapped down to a tick boundary. At quarter or year scale a visibleStart of 2025-03-14 becomes 2025-03-01. The last cell can still overshoot visibleEnd.
[!WARNING] With no tasks and only one end pinned, the chart renders nothing at all — no cells, no header, no error. Pin both ends to draw an empty calendar.
Growing it with infiniteScroll
infiniteScroll defaults to false. Off, the range is the tasks plus the buffer and the scroll container stops there.
On, the chart watches scrollLeft. When the view comes within half a viewport of either end, it adds about one viewport of ticks to that side. The exact chunk is ceil(viewportPx / pxPerTick) ticks, minimum one, where pxPerTick is the average tick width at the current scale. The front is checked first, so one pass never grows both sides.
Growth is capped at 2000 ticks per side. When cells are added in front, the chart shifts scrollLeft by exactly the added width, so what is on screen stays where it is.
Three consequences worth knowing:
- The check runs as soon as the listener is attached, before any scrolling. A chart that mounts at
scrollLeft: 0is already within half a viewport of the front, so it extends immediately. - A pinned end never grows. With
visibleStartandvisibleEndboth set,infiniteScrolldoes nothing. - The accumulated extension is measured in ticks and tagged with the scale it was measured in. Changing scale drops it and refits from the tasks.
Reading it back with onRangeChange
onRangeChange receives a GanttDateRange of two Dayjs values. start is the first cell's start. end is the exclusive end of the last cell, that is the last cell's start plus one tick unit. Querying [start, end] inclusively double-counts the last tick.
// Roadmap.tsx
import { useCallback } from "react";
import {
ReactGanttChart,
type GanttDateRange,
type Task,
} from "@jaeungkim/gantt-chart";
import "@jaeungkim/gantt-chart/style.css";
const tasks: Task[] = [
{
id: "spec",
name: "Spec",
startDate: "2025-06-10",
endDate: "2025-06-13",
parentId: null,
sequence: "1",
},
];
export function Roadmap() {
const handleRangeChange = useCallback((range: GanttDateRange) => {
const from = range.start.toISOString();
const to = range.end.toISOString();
void fetch(`/api/tasks?from=${from}&to=${to}`);
}, []);
return (
<ReactGanttChart
tasks={tasks}
defaultScale="month"
infiniteScroll
zoomOnWheel
onRangeChange={handleRangeChange}
/>
);
}The callback fires on the first render that produces cells, then on every change of those two dates. It is deduped by start and end milliseconds, so a rebuild landing on the same dates is silent.
That dedupe makes three of the five zoom steps report nothing. hour and day share a tickUnit of hour, week and month share day, quarter and year share month. Paired scales produce identical cell dates and differ only in width, so switching between them fires no callback.
The handler is read through a ref rather than a dependency. Swapping its identity does not re-fire, and a handler supplied after the first render only sees the next real change.
Zoom
zoomOnWheel defaults to false, because Ctrl and wheel is the browser's own page zoom. Turn it on and the chart claims that gesture inside its own bounds.
The ladder is the scale table in order, finest first: hour, day, week, month, quarter, year. Wheel down zooms out, matching the browser. The ladder clamps at both ends; at hour a further zoom-in does nothing. The scale selector buttons wrap on arrow keys instead, described in Keyboard and screen readers.
Steps are accumulated, not counted per event. A wheel gesture has to reach 24 of accumulated deltaY to move one step. Once it has stepped, the gesture is locked and every further event is swallowed until a pause of 120ms. A twenty-event trackpad pinch is one step. Two flicks 160ms apart are two.
The date under the cursor stays at the same x position across the change. A cursor over the task list pane is clamped to the timeline's left edge. A cursor past the last rendered cell cancels the zoom entirely, because there is no date there to anchor to.
Plain wheel still scrolls vertically and Shift and wheel still scrolls horizontally, with zoomOnWheel on or off. Only the Ctrl and Cmd variants change meaning.
To fit every visible bar into the viewport in one call, use zoomToFit() on the chart ref, described in Imperative API.
Markers and range bands
A marker is a vertical line at one date. A range band is a shaded block between two. Both take a color or a className, and both are decoration: they are aria-hidden, they take no pointer events, and there is no click or hover callback.
GanttMarker:
| field | type | what it does |
|---|---|---|
id | string | React key; defaults to the date plus the array index |
date | string | Date | Dayjs | where the line is drawn |
label | string | small pill at the top of the line; omitted, the line is bare |
className | string | added next to gantt-marker on the element |
color | string | any CSS color; written inline as --gantt-marker-color |
warnOnOverrun | boolean | flags the marker when a task ends past its date |
taskIds | string[] | limits warnOnOverrun to these tasks |
GanttRangeBand:
| field | type | what it does |
|---|---|---|
id | string | React key; defaults to the start date plus the array index |
startDate | string | Date | Dayjs | left edge |
endDate | string | Date | Dayjs | right edge, exclusive |
label | string | small caption in the top-left of the band |
className | string | added next to gantt-range-band |
color | string | any CSS color; written inline as --gantt-band-color |
Full type definitions are in Markers.
// ReleasePlan.tsx
import {
ReactGanttChart,
type GanttMarker,
type GanttRangeBand,
type Task,
} from "@jaeungkim/gantt-chart";
const tasks: Task[] = [
{
id: "spec",
name: "Spec",
startDate: "2025-06-10",
endDate: "2025-06-13",
parentId: null,
sequence: "1",
},
{
id: "build",
name: "Build",
startDate: "2025-06-16",
endDate: "2025-06-27",
parentId: null,
sequence: "2",
},
];
const markers: GanttMarker[] = [
{ id: "ga", date: "2025-07-01", label: "GA", color: "#0ea5e9" },
{
id: "freeze",
date: "2025-06-25",
label: "Code freeze",
warnOnOverrun: true,
taskIds: ["build"],
},
];
const rangeBands: GanttRangeBand[] = [
{
id: "sprint-12",
startDate: "2025-06-09",
endDate: "2025-06-23",
label: "Sprint 12",
},
];
export function ReleasePlan() {
return (
<ReactGanttChart tasks={tasks} markers={markers} rangeBands={rangeBands} />
);
}A marker whose date falls outside the rendered range is dropped. An unparseable date is skipped. A band is clipped to the visible part when it overlaps an edge, and dropped when endDate is at or before startDate — a zero-length band renders nothing.
The today line
The chart prepends its own marker to your list: id: "today", className: "gantt-today-marker", no label. There is no prop to hide or rename it. It goes through the same placement code, so it disappears when today is outside the rendered range.
The date is read when the marker list is recomputed. There is no timer, so the line does not move on its own at midnight.
Two collisions to avoid. A marker of your own with id: "today" produces a duplicate React key. A marker of your own with className="gantt-today-marker" picks up a rule that paints the line from --gantt-today-marker directly, so color stops reaching the line while the label still honours it.
Overrun warnings
warnOnOverrun: true compares the marker's date against every task's endDate. The comparison is strictly greater, so a task ending exactly on the marker date is not an overrun. warnOnOverrun is checked with ===, so a truthy value that is not literally true does nothing.
Without taskIds, every task in the chart is checked, including tasks hidden inside a collapsed subtree. With taskIds, only the listed ids.
A flagged marker gets data-warning="true" on its element. The shipped stylesheet points that element's --gantt-marker-color at --gantt-marker-warning, amber by default. Nothing else about the marker changes.
/* app.css - loaded after @jaeungkim/gantt-chart/style.css */
:root {
--gantt-marker-warning: #dc2626;
}
.gantt-marker[data-warning="true"] {
width: 3px;
}A color on the same marker wins over the warning styling, because color writes --gantt-marker-color as an inline style and the warning rule sets that same variable from a stylesheet. Such a marker keeps its custom color forever, and only data-warning in the DOM reveals the state. Leave color off any marker that should turn amber. The rest of the theme tokens are in Theming.
Non-working days
showNonWorkingDays defaults to true. It shades weekends and holidays as a background layer behind the bars.
holidays is a list of ISO YYYY-MM-DD strings, matched against each tick's UTC day. The default predicate is Sunday, Saturday, or a holidays entry.
isNonWorkingDay replaces that predicate wholesale. A chart that passes it never reads holidays, and never applies the weekend rule unless the predicate says so.
// SixDayWeek.tsx
import { ReactGanttChart, type Task } from "@jaeungkim/gantt-chart";
const tasks: Task[] = [
{
id: "spec",
name: "Spec",
startDate: "2025-06-10",
endDate: "2025-06-13",
parentId: null,
sequence: "1",
},
];
const HOLIDAYS = ["2025-06-06", "2025-08-15"];
export function SixDayWeek() {
return (
<ReactGanttChart
tasks={tasks}
showNonWorkingDays
// Sundays and the listed holidays only - Saturday is a working day here.
isNonWorkingDay={(date) =>
date.day() === 0 || HOLIDAYS.includes(date.format("YYYY-MM-DD"))
}
/>
);
}Shading is decided by the scale's tickUnit, not by the scale's name:
| scale | tickUnit | shaded |
|---|---|---|
hour | hour | yes |
day | hour | yes |
week | day | yes |
month | day | yes |
quarter | month | no |
year | month | no |
Zoom out to quarter or year and the shading disappears. A month tick cannot be half non-working, so the layer is skipped there.
Adjacent non-working ticks merge into one block, so a weekend at month scale is a single 64px band. At hour and day scale the predicate is asked once per hour tick, so one non-working day is 24 consecutive ticks merged into one block.
This is paint, and only paint. Making non-working days actually count when a duration or a dependency is calculated is workingCalendar, described in Scheduling.
Limits
- No pixel-per-day control. Tick widths come from
basePxPerDragStep / dragStepAmount, and neither is a prop. There is nopxPerDay, no zoom percentage, and no fractional or animated zoom — zoom is a discrete jump between six scales. - No seventh scale. The scale table is a module constant. A host that needs a fortnight column cannot add one.
- The timeline internals are not exported. The scale config, the ladder, and the cell-building utilities stay inside the package.
GanttMarker,GanttRangeBand,GanttDateRangeandGanttScaleKeyare exported; the geometry is not. - The today line cannot be switched off with a prop. CSS on
.gantt-today-markeris the only way to hide it, and nothing refreshes it at midnight. - Markers and bands are not interactive. No click, no hover, no tooltip, no screen-reader text. An interactive deadline is the host app's own overlay.
- Anything outside the rendered range is silently dropped. A deadline marker six months past the last task is not drawn, and there is no warning. Widen the range with
visibleEndorinfiniteScrollfirst. - Nothing here validates dates. An invalid marker date is skipped, an inverted band is dropped, and tasks whose dates are all unparseable produce an empty timeline rather than an error.
- Shading and scheduling are separate switches.
showNonWorkingDays={false}hides the bands and nothing else. WithworkingCalendaron,holidaysandisNonWorkingDaystill drive every date calculation — see Scheduling. - The built-in weekend is Saturday and Sunday.
firstDayOfWeekregroups the week scale's header and moves no shading. A Friday–Saturday weekend needsisNonWorkingDay. zoomOnWheelclaims every Ctrl and Cmd wheel event.preventDefaultruns before the accumulator, so page zoom is blocked inside the chart even on the events that produce no scale step.- Pinning is horizontal only.
visibleStart,visibleEndandinfiniteScrollsay nothing about rows. - Scroll position is not controlled. There is no
scrollLeftprop and noonScroll.onRangeChangereports dates, never pixels, and scrolling programmatically goes through the chart ref.
Next: Editing tasks — what a drag on a bar is allowed to change, and where it snaps.