ZaidanHomeDocsComponentsBlocksChartsTypesetCreate

Command Palette

Search for a command to run...

GitHub
New
Sections
  • Introduction
  • Components
  • Blocks
  • Installation
  • Customization
  • Dark Mode
  • Typeset
  • Zaidan Skills
  • FAQ
  • Roadmap
  • Changelog
Installation
  • Astro
  • Manual
  • Solid Start
  • TanStack Router
  • TanStack Start
  • Vite
Blocks
  • Data Grid
  • Event Calendar
  • Filters
  • Gantt
  • Image Crop
  • Kanban
  • Message Scroller
  • Questionnaire
  • Sortable
Components
  • Accordion
  • Alert
  • Alert Dialog
  • Aspect Ratio
  • Attachment
  • Avatar
  • Badge
  • Breadcrumb
  • Bubble
  • Button
  • Button Group
  • Calendar
  • Card
  • Carousel
  • Chart
  • Checkbox
  • Collapsible
  • Combobox
  • Command
  • Context Menu
  • Dialog
  • Drawer
  • Dropdown Menu
  • Empty
  • Field
  • Hover Card
  • Input
  • Input Group
  • Input OTP
  • Item
  • Kbd
  • Label
  • Marker
  • Menubar
  • Message
  • Native Select
  • Navigation Menu
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Resizable
  • Scroll Area
  • Select
  • Separator
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Switch
  • Table
  • Tabs
  • Textarea
  • Toast
  • Toggle
  • Toggle Group
  • Tooltip

Gantt

August 2026
Resources
Planning
Project brief
Scope review
Design
Wireframes
Visual design
Build
Frontend
Backend
QA pass
Release prep
Launch
Docs
Marketing site
Announcement
W31 Jul 26 - 1
W32 Aug 2 - 8
W33 Aug 9 - 15
W34 Aug 16 - 22
W35 Aug 23 - 29
W36 Aug 30 - 5
Sat 1
Sun 2
Mon 3
Tue 4
Wed 5
Thu 6
Fri 7
Sat 8
Sun 9
Mon 10
Tue 11
Wed 12
Thu 13
Fri 14
Sat 15
Sun 16
Mon 17
Tue 18
Wed 19
Thu 20
Fri 21
Sat 22
Sun 23
Mon 24
Tue 25
Wed 26
Thu 27
Fri 28
Sat 29
Sun 30
Mon 31
100%
55%
4%
1
import { addDays, type Locale, startOfDay, startOfWeek } from "date-fns";
2
import { ar, de, es, fr, ja } from "date-fns/locale";
3
import { RotateCcw, Settings } from "lucide-solid";
4
import type { JSX } from "solid-js";
5
import { createStore } from "solid-js/store";
6
import {
7
Gantt,
8
type GanttApi,
9
type GanttEvent,
10
type GanttI18nOverrides,
11
type GanttInteractions,
12
GanttNav,
13
type GanttResource,
14
type GanttSlotDraft,
15
GanttToolbar,
16
GanttView,
17
} from "@/registry/kobalte/blocks/gantt";
18
import { Button } from "~/components/ui/button";
19
import { Card, CardContent } from "~/components/ui/card";
20
import { Label } from "~/components/ui/label";
21
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
22
import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group";
23
import {
24
Select,
25
SelectContent,
26
SelectItem,
27
SelectTrigger,
28
SelectValue,
29
} from "~/components/ui/select";
30
import { Switch } from "~/components/ui/switch";
31
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
32
33
/**
34
* White-label task tree, tall enough that the panes scroll vertically. The
35
* Launch group and "Release prep" ship UNSCHEDULED: hover their empty rows
36
* and click the hint tile (or drag a range) to schedule them.
37
*/
38
const RESOURCES: GanttResource[] = [
39
{
40
id: "planning",
41
title: "Planning",
42
children: [
43
{ id: "brief", title: "Project brief" },
44
{ id: "scope", title: "Scope review" },
45
],
46
},
47
{
48
id: "design",
49
title: "Design",
50
children: [
51
{ id: "wireframes", title: "Wireframes" },
52
{ id: "visual-design", title: "Visual design" },
53
],
54
},
55
{
56
id: "build",
57
title: "Build",
58
children: [
59
{ id: "frontend", title: "Frontend" },
60
{ id: "backend", title: "Backend" },
61
{ id: "qa", title: "QA pass" },
62
{ id: "release-prep", title: "Release prep" },
63
],
64
},
65
{
66
id: "launch",
67
title: "Launch",
68
children: [
69
{ id: "docs", title: "Docs" },
70
{ id: "marketing-site", title: "Marketing site" },
71
{ id: "announcement", title: "Announcement" },
72
],
73
},
74
];
75
76
/** Leaf id -> title, for naming bars scheduled from empty rows. */
77
const RESOURCE_TITLES = new Map(
78
RESOURCES.flatMap((group) => group.children ?? []).map((leaf) => [leaf.id, leaf.title]),
79
);
80
81
/** Small white-label fixture built around the current week. */
82
function buildBars(anchor: Date): GanttEvent[] {
83
const week = startOfWeek(startOfDay(anchor), { weekStartsOn: 0 });
84
const day = (dayOffset: number) => addDays(week, dayOffset);
85
const bar = (
86
resourceId: string,
87
title: string,
88
startOffset: number,
89
days: number,
90
color: string,
91
progress?: number,
92
): GanttEvent => ({
93
id: `bar-${resourceId}`,
94
title,
95
start: day(startOffset),
96
end: day(startOffset + days),
97
allDay: true,
98
color,
99
resourceId,
100
progress,
101
});
102
103
return [
104
bar("brief", "Project brief", -9, 3, "var(--color-blue-500)", 100),
105
bar("scope", "Scope review", -6, 2, "var(--color-sky-500)", 100),
106
bar("wireframes", "Wireframes", -4, 4, "var(--color-violet-500)", 80),
107
bar("visual-design", "Visual design", 0, 5, "var(--color-purple-500)", 35),
108
bar("frontend", "Frontend", 3, 7, "var(--color-emerald-500)", 10),
109
bar("backend", "Backend", 5, 6, "var(--color-teal-500)"),
110
bar("qa", "QA pass", 12, 4, "var(--color-amber-500)"),
111
];
112
}
113
114
/**
115
* i18n presets - each language ships a date-fns `locale` (localizes the axis
116
* headers, the nav title, and bar date labels; it also drives the default
117
* week start, so a German timeline starts Monday) plus an `i18n` override map
118
* for the static strings the locale can't reach (Today, the scale names, the
119
* schedule hint). Arabic also flips the chart to right-to-left. English is the
120
* built-in default, so it leaves both undefined.
121
*/
122
type DemoLocale = {
123
id: string;
124
/** Native language name, shown in the picker. */
125
label: string;
126
locale: Locale | undefined;
127
dir: "ltr" | "rtl";
128
i18n: GanttI18nOverrides | undefined;
129
};
130
131
const LOCALES: DemoLocale[] = [
132
{ id: "en", label: "English", locale: undefined, dir: "ltr", i18n: undefined },
133
{
134
id: "de",
135
label: "Deutsch",
136
locale: de,
137
dir: "ltr",
138
i18n: {
139
labels: {
140
today: "Heute",
141
scheduleHint: "Zum Planen klicken",
142
reorder: "Neu anordnen",
143
scales: { day: "Tag", week: "Woche", month: "Monat", quarter: "Quartal", year: "Jahr" },
144
},
145
},
146
},
147
{
148
id: "fr",
149
label: "Français",
150
locale: fr,
151
dir: "ltr",
152
i18n: {
153
labels: {
154
today: "Aujourd'hui",
155
scheduleHint: "Cliquer pour planifier",
156
reorder: "Réorganiser",
157
scales: {
158
day: "Jour",
159
week: "Semaine",
160
month: "Mois",
161
quarter: "Trimestre",
162
year: "Année",
163
},
164
},
165
},
166
},
167
{
168
id: "es",
169
label: "Español",
170
locale: es,
171
dir: "ltr",
172
i18n: {
173
labels: {
174
today: "Hoy",
175
scheduleHint: "Clic para programar",
176
reorder: "Reordenar",
177
scales: { day: "Día", week: "Semana", month: "Mes", quarter: "Trimestre", year: "Año" },
178
},
179
},
180
},
181
{
182
id: "ja",
183
label: "日本語",
184
locale: ja,
185
dir: "ltr",
186
i18n: {
187
labels: {
188
today: "今日",
189
scheduleHint: "クリックして予定を追加",
190
reorder: "並べ替え",
191
scales: { day: "日", week: "週", month: "月", quarter: "四半期", year: "年" },
192
},
193
},
194
},
195
{
196
id: "ar",
197
label: "العربية",
198
locale: ar,
199
dir: "rtl",
200
i18n: {
201
labels: {
202
today: "اليوم",
203
scheduleHint: "انقر لإضافة جدول",
204
reorder: "إعادة ترتيب",
205
scales: { day: "يوم", week: "أسبوع", month: "شهر", quarter: "ربع سنوي", year: "سنة" },
206
},
207
},
208
},
209
];
210
211
/** Display time zones - all timeline math and rendering happen in the chosen
212
* zone, so switching it re-anchors every bar to that zone's calendar days. */
213
const TIME_ZONES: Array<{ id: string; label: string; value?: string }> = [
214
{ id: "local", label: "Browser" },
215
{ id: "ny", label: "New York", value: "America/New_York" },
216
{ id: "london", label: "London", value: "Europe/London" },
217
{ id: "tokyo", label: "Tokyo", value: "Asia/Tokyo" },
218
{ id: "kolkata", label: "Kolkata", value: "Asia/Kolkata" },
219
];
220
221
type DemoSettings = {
222
rowCheckboxes: boolean;
223
summaryBars: boolean;
224
zoomControl: boolean;
225
offscreenIndicators: boolean;
226
infiniteScroll: boolean;
227
nowIndicator: boolean;
228
offDays: boolean;
229
dragCreate: boolean;
230
displayScheduleHint: boolean;
231
barLabel: "inside" | "outside" | "auto";
232
timelineLines: "vertical" | "both" | "none";
233
interactions: GanttInteractions;
234
localeId: string;
235
timeZoneId: string;
236
};
237
238
/** Every toggle's default; the Reset button returns the demo here. */
239
const SETTINGS_DEFAULTS: DemoSettings = {
240
rowCheckboxes: true,
241
summaryBars: true,
242
zoomControl: true,
243
offscreenIndicators: true,
244
infiniteScroll: true,
245
nowIndicator: true,
246
offDays: false,
247
dragCreate: true,
248
displayScheduleHint: true,
249
barLabel: "inside",
250
timelineLines: "vertical",
251
interactions: { drag: true, resize: true, selectSlot: true },
252
localeId: "en",
253
timeZoneId: "local",
254
};
255
256
/** One labeled switch row inside a settings tab. */
257
function SettingSwitch(props: {
258
id: string;
259
label: string;
260
checked: boolean;
261
onChange: (value: boolean) => void;
262
}) {
263
return (
264
<div class="flex items-center justify-between gap-4 py-1">
265
<Label for={props.id} class="font-normal">
266
{props.label}
267
</Label>
268
<Switch id={props.id} checked={props.checked} onChange={props.onChange} />
269
</div>
270
);
271
}
272
273
/** One labeled radio row inside a settings tab. */
274
function SettingRadio(props: { id: string; value: string; label: string }) {
275
return (
276
<div class="flex items-center gap-2 py-0.5">
277
<RadioGroupItem value={props.value} id={props.id} />
278
<Label for={props.id} class="font-normal">
279
{props.label}
280
</Label>
281
</div>
282
);
283
}
284
285
type SelectOption = { value: string; label: string };
286
287
/** One labeled select row - the language and time-zone pickers. */
288
function SettingSelect(props: {
289
id: string;
290
label: string;
291
value: string;
292
options: SelectOption[];
293
onValueChange: (value: string) => void;
294
}) {
295
const selected = () => props.options.find((option) => option.value === props.value);
296
297
return (
298
<div class="flex items-center justify-between gap-4 py-1">
299
<Label for={props.id} class="font-normal">
300
{props.label}
301
</Label>
302
<Select<SelectOption>
303
options={props.options}
304
optionValue="value"
305
optionTextValue="label"
306
value={selected()}
307
onChange={(option) => option && props.onValueChange(option.value)}
308
itemComponent={(itemProps) => (
309
<SelectItem item={itemProps.item}>{itemProps.item.rawValue.label}</SelectItem>
310
)}
311
>
312
<SelectTrigger id={props.id} size="sm" class="w-36" aria-label={props.label}>
313
{/* Kobalte's Value renders the raw value by default; the selected
314
option's label reads better here. */}
315
<SelectValue<SelectOption>>{(state) => state.selectedOption().label}</SelectValue>
316
</SelectTrigger>
317
<SelectContent />
318
</Select>
319
</div>
320
);
321
}
322
323
function SettingsMenu(props: {
324
settings: DemoSettings;
325
onChange: <K extends keyof DemoSettings>(key: K, value: DemoSettings[K]) => void;
326
onInteractionChange: (key: keyof GanttInteractions, value: boolean) => void;
327
onReset: () => void;
328
}): JSX.Element {
329
return (
330
<Popover placement="bottom-end">
331
<PopoverTrigger as={Button} variant="outline" size="sm">
332
<Settings class="size-4" aria-hidden="true" />
333
Settings
334
</PopoverTrigger>
335
<PopoverContent class="w-80 p-0">
336
{/* Tabs keep every group one screen tall - no menu scrolling */}
337
<Tabs defaultValue="display">
338
<div class="border-b p-2">
339
<TabsList class="grid w-full grid-cols-4">
340
<TabsTrigger value="display">Display</TabsTrigger>
341
<TabsTrigger value="behavior">Behavior</TabsTrigger>
342
<TabsTrigger value="style">Style</TabsTrigger>
343
<TabsTrigger value="region">Region</TabsTrigger>
344
</TabsList>
345
</div>
346
<TabsContent value="display" class="space-y-0.5 p-3">
347
<SettingSwitch
348
id="gantt-demo-row-checkboxes"
349
label="Row checkboxes"
350
checked={props.settings.rowCheckboxes}
351
onChange={(value) => props.onChange("rowCheckboxes", value)}
352
/>
353
<SettingSwitch
354
id="gantt-demo-summary-bars"
355
label="Summary bars"
356
checked={props.settings.summaryBars}
357
onChange={(value) => props.onChange("summaryBars", value)}
358
/>
359
<SettingSwitch
360
id="gantt-demo-zoom-control"
361
label="Zoom control"
362
checked={props.settings.zoomControl}
363
onChange={(value) => props.onChange("zoomControl", value)}
364
/>
365
<SettingSwitch
366
id="gantt-demo-offscreen-chips"
367
label="Off-screen chips"
368
checked={props.settings.offscreenIndicators}
369
onChange={(value) => props.onChange("offscreenIndicators", value)}
370
/>
371
<SettingSwitch
372
id="gantt-demo-infinite-scroll"
373
label="Infinite scroll"
374
checked={props.settings.infiniteScroll}
375
onChange={(value) => props.onChange("infiniteScroll", value)}
376
/>
377
<SettingSwitch
378
id="gantt-demo-now-indicator"
379
label="Now indicator"
380
checked={props.settings.nowIndicator}
381
onChange={(value) => props.onChange("nowIndicator", value)}
382
/>
383
<SettingSwitch
384
id="gantt-demo-off-days"
385
label="Mark off days"
386
checked={props.settings.offDays}
387
onChange={(value) => props.onChange("offDays", value)}
388
/>
389
</TabsContent>
390
<TabsContent value="behavior" class="space-y-0.5 p-3">
391
<SettingSwitch
392
id="gantt-demo-drag"
393
label="Drag to move"
394
checked={props.settings.interactions.drag}
395
onChange={(value) => props.onInteractionChange("drag", value)}
396
/>
397
<SettingSwitch
398
id="gantt-demo-resize"
399
label="Resize"
400
checked={props.settings.interactions.resize}
401
onChange={(value) => props.onInteractionChange("resize", value)}
402
/>
403
<SettingSwitch
404
id="gantt-demo-select-slot"
405
label="Select slot"
406
checked={props.settings.interactions.selectSlot}
407
onChange={(value) => props.onInteractionChange("selectSlot", value)}
408
/>
409
<SettingSwitch
410
id="gantt-demo-drag-create"
411
label="Drag to create"
412
checked={props.settings.dragCreate}
413
onChange={(value) => props.onChange("dragCreate", value)}
414
/>
415
<SettingSwitch
416
id="gantt-demo-schedule-hint"
417
label="Schedule hint"
418
checked={props.settings.displayScheduleHint}
419
onChange={(value) => props.onChange("displayScheduleHint", value)}
420
/>
421
</TabsContent>
422
<TabsContent value="style" class="space-y-4 p-3">
423
<div class="space-y-1.5">
424
<div class="font-medium text-muted-foreground text-xs">Bar label</div>
425
<RadioGroup
426
value={props.settings.barLabel}
427
onChange={(value) => props.onChange("barLabel", value as DemoSettings["barLabel"])}
428
>
429
<SettingRadio id="gantt-demo-label-inside" value="inside" label="Inside" />
430
<SettingRadio id="gantt-demo-label-outside" value="outside" label="Outside" />
431
<SettingRadio id="gantt-demo-label-auto" value="auto" label="Auto" />
432
</RadioGroup>
433
</div>
434
<div class="space-y-1.5">
435
<div class="font-medium text-muted-foreground text-xs">Grid lines</div>
436
<RadioGroup
437
value={props.settings.timelineLines}
438
onChange={(value) =>
439
props.onChange("timelineLines", value as DemoSettings["timelineLines"])
440
}
441
>
442
<SettingRadio id="gantt-demo-lines-vertical" value="vertical" label="Vertical" />
443
<SettingRadio id="gantt-demo-lines-both" value="both" label="Both" />
444
<SettingRadio id="gantt-demo-lines-none" value="none" label="None" />
445
</RadioGroup>
446
</div>
447
</TabsContent>
448
<TabsContent value="region" class="space-y-2 p-3">
449
<SettingSelect
450
id="gantt-demo-language"
451
label="Language"
452
value={props.settings.localeId}
453
options={LOCALES.map((entry) => ({ value: entry.id, label: entry.label }))}
454
onValueChange={(value) => props.onChange("localeId", value)}
455
/>
456
<SettingSelect
457
id="gantt-demo-timezone"
458
label="Time zone"
459
value={props.settings.timeZoneId}
460
options={TIME_ZONES.map((entry) => ({ value: entry.id, label: entry.label }))}
461
onValueChange={(value) => props.onChange("timeZoneId", value)}
462
/>
463
<p class="text-muted-foreground text-xs leading-relaxed">
464
Language switches the date-fns locale, the scale names, and the week start. Time zone
465
re-anchors every bar. Arabic also flips the chart to right-to-left.
466
</p>
467
</TabsContent>
468
</Tabs>
469
<div class="border-t p-2">
470
<Button variant="outline" size="sm" class="w-full" onClick={props.onReset}>
471
<RotateCcw class="size-3.5" aria-hidden="true" />
472
Reset to defaults
473
</Button>
474
</div>
475
</PopoverContent>
476
</Popover>
477
);
478
}
479
480
export default function GanttDemo() {
481
const bars = buildBars(new Date());
482
let api: GanttApi | undefined;
483
const [settings, setSettings] = createStore<DemoSettings>({
484
...SETTINGS_DEFAULTS,
485
interactions: { ...SETTINGS_DEFAULTS.interactions },
486
});
487
488
const activeLocale = () => LOCALES.find((entry) => entry.id === settings.localeId) ?? LOCALES[0];
489
const activeTimeZone = () =>
490
TIME_ZONES.find((entry) => entry.id === settings.timeZoneId) ?? TIME_ZONES[0];
491
492
const resetSettings = () =>
493
setSettings({ ...SETTINGS_DEFAULTS, interactions: { ...SETTINGS_DEFAULTS.interactions } });
494
495
// Unscheduled rows accept ONE schedule: the hint tile (or a drag-create
496
// range) proposes a slot, and the handler turns it into a real bar.
497
const canSelectSlot = (slot: GanttSlotDraft) =>
498
!!slot.resourceId &&
499
!(api?.getEvents() ?? []).some((event) => event.resourceId === slot.resourceId);
500
501
const handleSelectSlot = (slot: GanttSlotDraft) => {
502
if (!api || !slot.resourceId) return;
503
api.addEvent({
504
id: `scheduled-${slot.resourceId}`,
505
title: RESOURCE_TITLES.get(slot.resourceId) ?? "New schedule",
506
start: slot.start,
507
end: slot.end,
508
allDay: true,
509
color: "var(--color-indigo-500)",
510
resourceId: slot.resourceId,
511
});
512
};
513
514
return (
515
<div class="w-full p-4" dir={activeLocale().dir}>
516
<Card class="w-full py-0">
517
<CardContent class="p-0">
518
<Gantt
519
defaultEvents={bars}
520
resources={RESOURCES}
521
defaultScale="month"
522
apiRef={(instance) => {
523
api = instance;
524
}}
525
locale={activeLocale().locale}
526
i18n={activeLocale().i18n}
527
timeZone={activeTimeZone().value}
528
treePanel={{ width: 200 }}
529
rowCheckboxes={settings.rowCheckboxes}
530
summaryBars={settings.summaryBars}
531
zoomControl={settings.zoomControl}
532
offscreenIndicators={settings.offscreenIndicators}
533
infiniteScroll={settings.infiniteScroll}
534
nowIndicator={settings.nowIndicator}
535
offDays={settings.offDays}
536
dragCreate={settings.dragCreate}
537
displayScheduleHint={settings.displayScheduleHint}
538
barLabel={settings.barLabel}
539
timelineLines={settings.timelineLines}
540
interactions={settings.interactions}
541
onInteractionsChange={(next) => setSettings("interactions", next)}
542
canSelectSlot={canSelectSlot}
543
onSelectSlot={handleSelectSlot}
544
class="h-[520px] w-full"
545
>
546
{/* one bordered header row, same look as the plain GanttNav:
547
the row owns the border and end padding so the toolbar never
548
sits glued to the edge */}
549
<div class="flex flex-wrap items-center gap-2 border-b pe-3">
550
<GanttNav class="min-w-0 flex-1 border-b-0" />
551
<GanttToolbar>
552
<SettingsMenu
553
settings={settings}
554
onChange={(key, value) => setSettings(key, value)}
555
onInteractionChange={(key, value) => setSettings("interactions", key, value)}
556
onReset={resetSettings}
557
/>
558
</GanttToolbar>
559
</div>
560
<GanttView />
561
</Gantt>
562
</CardContent>
563
</Card>
564
</div>
565
);
566
}

The gantt block ships a headless engine (useGanttState) and a composable view layer on top of it: a resizable tree pane for the node hierarchy, a horizontal timeline with day, week, month, quarter, and year scales, zoom, infinite scrolling, drag and resize scheduling with live validation, per-bar progress fills, and duration-weighted summary rollups on parent rows. The engine never mutates your data on its own; every timing change flows through one proposal funnel (onEventUpdate, canDropEvent) so external CRUD stays in your hands.

The composition contract is <Gantt><GanttNav /><GanttView /></Gantt>. The root provides the calendar instance and the view configuration through context; the nav family, the view, and GanttBar all read from it, so any piece can be replaced with your own markup driven by the same hooks.

Installation

pnpm dlx shadcn@latest add @zaidan/gantt
npx shadcn@latest add @zaidan/gantt
yarn dlx shadcn@latest add @zaidan/gantt
bunx --bun shadcn@latest add @zaidan/gantt

Usage

1
import {
2
Gantt,
3
type GanttEvent,
4
GanttNav,
5
type GanttResource,
6
GanttToolbar,
7
GanttView,
8
} from "~/components/blocks/gantt";
1
const resources: GanttResource[] = [
2
{
3
id: "design",
4
title: "Design",
5
children: [
6
{ id: "wireframes", title: "Wireframes" },
7
{ id: "visual-design", title: "Visual design" },
8
],
9
},
10
];
11
12
const events: GanttEvent[] = [
13
{
14
id: "1",
15
title: "Wireframes",
16
start: new Date("2026-07-06"),
17
end: new Date("2026-07-10"),
18
allDay: true,
19
resourceId: "wireframes",
20
progress: 60,
21
},
22
];
23
24
return (
25
<Gantt defaultEvents={events} resources={resources} defaultScale="month" class="h-[480px]">
26
<GanttNav />
27
<GanttView />
28
</Gantt>
29
);

Give the root an explicit height (class="h-[480px]" or a flex parent): the root is a min-height-zero flex column and the view fills whatever it gets. The root also owns the type scale: every gantt label inherits its text size (text-xs by default), so class="text-sm" scales the whole component up in one place. Events work controlled (events + onEventsChange) or uncontrolled (defaultEvents); the same pairs exist for scale, date, selection, and interactions. Every drag, resize, and API timing change is proposed through onEventUpdate before it commits, canDropEvent validates live during the gesture, and a false return reverts the bar with no cleanup on your side, which makes persisting to a backend a matter of handling one callback.

The demo at the top of this page is the full composition: its settings menu drives every view configuration and interaction flag as controlled props from consumer state, and the unscheduled Launch tasks show the slot-selection contract — hover an empty row and click the hint tile (or drag a range) to schedule it through onSelectSlot.

Examples

Annual Product Roadmap

Q3 2026
Resources
Platform
Auth Revamp
API v2
Growth
Onboarding Flow
Referral Program
Design System
Design Tokens
Component Library
Backlog
Search Revamp
Billing v2
Mobile App
June
July
August
September
Jun 28
Jul 5
Jul 12
Jul 19
Jul 26
Aug 2
Aug 9
Aug 16
Aug 23
Aug 30
Sep 6
Sep 13
Sep 20
Sep 27
50%
0%
50%
1
import { addDays, startOfDay, startOfWeek } from "date-fns";
2
import { ChevronLeft, ChevronRight, Plus } from "lucide-solid";
3
import { createSignal } from "solid-js";
4
import {
5
Gantt,
6
type GanttApi,
7
type GanttEvent,
8
GanttNav,
9
type GanttResource,
10
GanttToolbar,
11
GanttView,
12
} from "@/registry/kobalte/blocks/gantt";
13
import { Button } from "~/components/ui/button";
14
import { Card, CardContent } from "~/components/ui/card";
15
import { ContextMenuItem } from "~/components/ui/context-menu";
16
17
/**
18
* Yearly roadmap: each workstream is a swimlane group and its multi-month
19
* initiatives are the bars. The Backlog group ships with empty rows - the
20
* toolbar button schedules the next one onto the timeline.
21
*/
22
const RESOURCES: GanttResource[] = [
23
{
24
id: "platform",
25
title: "Platform",
26
children: [
27
{ id: "auth-revamp", title: "Auth Revamp" },
28
{ id: "api-v2", title: "API v2" },
29
],
30
},
31
{
32
id: "growth",
33
title: "Growth",
34
children: [
35
{ id: "onboarding", title: "Onboarding Flow" },
36
{ id: "referrals", title: "Referral Program" },
37
],
38
},
39
{
40
id: "design-system",
41
title: "Design System",
42
children: [
43
{ id: "tokens", title: "Design Tokens" },
44
{ id: "components", title: "Component Library" },
45
],
46
},
47
{
48
id: "backlog",
49
title: "Backlog",
50
children: [
51
{ id: "search", title: "Search Revamp" },
52
{ id: "billing", title: "Billing v2" },
53
{ id: "mobile", title: "Mobile App" },
54
],
55
},
56
];
57
58
/** Backlog initiatives, scheduled one per click in this order. */
59
const BACKLOG: Array<{ id: string; title: string; color: string }> = [
60
{ id: "search", title: "Search Revamp", color: "var(--color-rose-500)" },
61
{ id: "billing", title: "Billing v2", color: "var(--color-amber-500)" },
62
{ id: "mobile", title: "Mobile App", color: "var(--color-cyan-500)" },
63
];
64
65
/** Roadmap fixture - initiatives span months so the quarter axis has something
66
* to show, and offsets straddle today so both past and future are visible. */
67
function buildBars(anchor: Date): GanttEvent[] {
68
const week = startOfWeek(startOfDay(anchor), { weekStartsOn: 0 });
69
const day = (dayOffset: number) => addDays(week, dayOffset);
70
const bar = (
71
resourceId: string,
72
title: string,
73
startOffset: number,
74
days: number,
75
color: string,
76
progress?: number,
77
): GanttEvent => ({
78
id: `bar-${resourceId}`,
79
title,
80
start: day(startOffset),
81
end: day(startOffset + days),
82
allDay: true,
83
color,
84
resourceId,
85
progress,
86
});
87
88
return [
89
bar("auth-revamp", "Auth Revamp", -30, 60, "var(--color-blue-500)", 100),
90
bar("api-v2", "API v2", 20, 90, "var(--color-sky-500)", 30),
91
bar("onboarding", "Onboarding Flow", -20, 60, "var(--color-emerald-500)", 80),
92
bar("referrals", "Referral Program", 50, 90, "var(--color-teal-500)", 0),
93
bar("tokens", "Design Tokens", -60, 70, "var(--color-violet-500)", 100),
94
bar("components", "Component Library", 0, 120, "var(--color-purple-500)", 45),
95
];
96
}
97
98
export default function GanttRoadmap() {
99
const bars = buildBars(new Date());
100
let api: GanttApi | undefined;
101
// How many backlog initiatives have been scheduled so far.
102
const [scheduled, setScheduled] = createSignal(0);
103
104
// Schedule the next unscheduled backlog initiative onto its (empty) row.
105
// The "next" one is derived from the live events, not a captured counter,
106
// so it stays correct even if the button is clicked in quick succession.
107
const addInitiative = () => {
108
if (!api) return;
109
const scheduledIds = new Set(api.getEvents().map((event) => event.id));
110
const index = BACKLOG.findIndex((item) => !scheduledIds.has(`bar-${item.id}`));
111
if (index === -1) return;
112
const item = BACKLOG[index];
113
const week = startOfWeek(startOfDay(new Date()), { weekStartsOn: 0 });
114
// Land the scheduled bars in the near-future part of the current quarter so
115
// each one is visible the moment it drops onto its row.
116
const start = addDays(week, 12 + index * 20);
117
api.addEvent({
118
id: `bar-${item.id}`,
119
title: item.title,
120
start,
121
end: addDays(start, 24),
122
allDay: true,
123
color: item.color,
124
resourceId: item.id,
125
});
126
setScheduled((count) => count + 1);
127
};
128
129
// Slide one initiative a quarter in either direction. Timing changes made
130
// through the api route through `onEventUpdate` exactly like a drag does,
131
// with `source: "api"` on the proposal.
132
const shiftInitiative = (eventId: string, days: number) => {
133
const event = api?.getEvent(eventId);
134
if (!api || !event) return;
135
api.updateEvent(eventId, {
136
start: addDays(event.start, days),
137
end: addDays(event.end, days),
138
});
139
};
140
141
return (
142
<div class="w-full p-4">
143
<Card class="w-full py-0">
144
<CardContent class="p-0">
145
<Gantt
146
defaultEvents={bars}
147
resources={RESOURCES}
148
defaultScale="quarter"
149
apiRef={(instance) => {
150
api = instance;
151
}}
152
treePanel={{ width: 200 }}
153
// A workstream is done when its initiatives are, so the group
154
// rollups count finished initiatives instead of the default
155
// duration-weighted mean progress.
156
getSummaryProgress={(ctx) => {
157
const scored = ctx.events.filter((event) => event.progress !== undefined);
158
if (scored.length === 0) return null;
159
const done = scored.filter((event) => (event.progress ?? 0) >= 100).length;
160
return Math.round((done / scored.length) * 100);
161
}}
162
// Right-click an initiative to reschedule it. The gantt owns the
163
// context menu; the items are yours.
164
renderEventMenu={(ctx) => (
165
<>
166
<ContextMenuItem onSelect={() => shiftInitiative(ctx.occurrence.eventId, -90)}>
167
<ChevronLeft aria-hidden="true" />
168
Pull in a quarter
169
</ContextMenuItem>
170
<ContextMenuItem onSelect={() => shiftInitiative(ctx.occurrence.eventId, 90)}>
171
<ChevronRight aria-hidden="true" />
172
Push out a quarter
173
</ContextMenuItem>
174
</>
175
)}
176
class="h-[480px] w-full"
177
>
178
<div class="flex flex-wrap items-center gap-2 border-b pe-3">
179
<GanttNav class="min-w-0 flex-1 border-b-0" />
180
<GanttToolbar>
181
<Button
182
variant="outline"
183
size="sm"
184
onClick={addInitiative}
185
disabled={scheduled() >= BACKLOG.length}
186
>
187
<Plus class="size-4" aria-hidden="true" />
188
Add to roadmap
189
</Button>
190
</GanttToolbar>
191
</div>
192
{/* Parent workstream rows carry no bars - summaryBars rolls up the
193
child initiatives into one envelope on the group row. */}
194
<GanttView />
195
</Gantt>
196
</CardContent>
197
</Card>
198
</div>
199
);
200
}

A long-horizon plan on the quarter scale. Workstreams are swimlane groups whose multi-month initiatives roll up into summary bars, so leadership reads the whole year at a glance and navigates a quarter at a time. The toolbar button schedules the next backlog initiative onto its empty row through the addEvent API, getSummaryProgress replaces the default rollup math with a count of finished initiatives, and renderEventMenu puts "push out a quarter" on every bar's right-click menu.

Team Capacity Schedule

August 23 - 29, 2026
Resources
Product Squad
ALAda Lovelace
ATAlan Turing
Design Squad
GHGrace Hopper
LTLinus Torvalds
W35 Aug 23 - 29
Sun 23
Mon 24
Tue 25
Wed 26
Thu 27
Fri 28
Sat 29
1
import { addDays, startOfDay, startOfWeek } from "date-fns";
2
import { Plus } from "lucide-solid";
3
import {
4
Gantt,
5
type GanttApi,
6
type GanttEvent,
7
GanttNav,
8
type GanttResource,
9
GanttToolbar,
10
GanttView,
11
} from "@/registry/kobalte/blocks/gantt";
12
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
13
import { Button } from "~/components/ui/button";
14
import { Card, CardContent } from "~/components/ui/card";
15
16
/**
17
* People-centric capacity board: rows are teammates grouped by squad, and
18
* each teammate's bars are the week's assignments. The toolbar drops a fresh
19
* assignment onto the next teammate, so a person can hold several at once.
20
*/
21
const RESOURCES: GanttResource[] = [
22
{
23
id: "product-squad",
24
title: "Product Squad",
25
children: [
26
{ id: "ada", title: "Ada Lovelace" },
27
{ id: "alan", title: "Alan Turing" },
28
],
29
},
30
{
31
id: "design-squad",
32
title: "Design Squad",
33
children: [
34
{ id: "grace", title: "Grace Hopper" },
35
{ id: "linus", title: "Linus Torvalds" },
36
],
37
},
38
];
39
40
/** Avatar photo + initials keyed by person id - GanttResource carries no
41
* custom fields, so per-row presentation data lives in a lookup of your own.
42
* The initials show while the image loads or if it fails. */
43
const RESOURCE_META: Record<string, { initials: string; avatar: string }> = {
44
ada: { initials: "AL", avatar: "https://randomuser.me/api/portraits/women/44.jpg" },
45
alan: { initials: "AT", avatar: "https://randomuser.me/api/portraits/men/32.jpg" },
46
grace: { initials: "GH", avatar: "https://randomuser.me/api/portraits/women/68.jpg" },
47
linus: { initials: "LT", avatar: "https://randomuser.me/api/portraits/men/54.jpg" },
48
};
49
50
/** Teammates the new assignments cycle through, and a small pool to name and
51
* color them from. */
52
const PEOPLE = ["ada", "alan", "grace", "linus"];
53
const TASK_POOL = [
54
{ title: "Bug triage", color: "var(--color-amber-500)" },
55
{ title: "Code review", color: "var(--color-rose-500)" },
56
{ title: "Spec draft", color: "var(--color-teal-500)" },
57
{ title: "Pairing", color: "var(--color-indigo-500)" },
58
];
59
60
/** This-week assignments per teammate - day-precise bars for a capacity read
61
* (progress omitted; occupancy, not percent-done, is the point here). */
62
function buildBars(anchor: Date): GanttEvent[] {
63
const week = startOfWeek(startOfDay(anchor), { weekStartsOn: 0 });
64
const day = (dayOffset: number) => addDays(week, dayOffset);
65
const bar = (
66
resourceId: string,
67
title: string,
68
startOffset: number,
69
days: number,
70
color: string,
71
): GanttEvent => ({
72
id: `bar-${resourceId}`,
73
title,
74
start: day(startOffset),
75
end: day(startOffset + days),
76
allDay: true,
77
color,
78
resourceId,
79
});
80
81
return [
82
bar("ada", "Checkout API", -1, 3, "var(--color-blue-500)"),
83
bar("alan", "Search Indexing", 0, 3, "var(--color-sky-500)"),
84
bar("grace", "Dashboard Redesign", 1, 3, "var(--color-violet-500)"),
85
bar("linus", "Infra Migration", -2, 3, "var(--color-purple-500)"),
86
];
87
}
88
89
/** Company holiday: the Thursday of the rendered week. */
90
function holiday() {
91
return addDays(startOfWeek(startOfDay(new Date()), { weekStartsOn: 0 }), 4);
92
}
93
94
export default function GanttCapacity() {
95
const bars = buildBars(new Date());
96
let api: GanttApi | undefined;
97
// A plain counter (not a signal) numbers the adds, so it advances
98
// synchronously and stays correct when the button is clicked several
99
// times in a row. Nothing in the UI reads it, so nothing has to react.
100
let added = 0;
101
102
// Add a 2-day assignment to the next teammate in rotation. Successive adds
103
// to the same person overlap and stack into extra lanes on that row.
104
const addAssignment = () => {
105
if (!api) return;
106
const n = added++;
107
const person = PEOPLE[n % PEOPLE.length];
108
const task = TASK_POOL[n % TASK_POOL.length];
109
const week = startOfWeek(startOfDay(new Date()), { weekStartsOn: 0 });
110
const start = addDays(week, (n % 5) + 1);
111
api.addEvent({
112
id: `bar-extra-${n}`,
113
title: task.title,
114
start,
115
end: addDays(start, 2),
116
allDay: true,
117
color: task.color,
118
resourceId: person,
119
});
120
};
121
122
return (
123
<div class="w-full p-4">
124
<Card class="w-full py-0">
125
<CardContent class="p-0">
126
<Gantt
127
defaultEvents={bars}
128
resources={RESOURCES}
129
defaultScale="week"
130
apiRef={(instance) => {
131
api = instance;
132
}}
133
// Weekends shaded so booked working-day capacity is obvious, plus
134
// one company holiday. A custom `class` replaces the default
135
// marker surface outright.
136
offDays={{
137
weekendDays: [0, 6],
138
dates: [holiday()],
139
class: "bg-muted/50",
140
}}
141
// Rows grow as assignments stack into extra lanes; centering keeps
142
// each teammate's avatar against the middle of their own row.
143
rowAlign="center"
144
treePanel={{ width: 220 }}
145
// People rows get an avatar label; group (squad) rows return
146
// undefined to keep the default plain-title label.
147
renderResourceLabel={(ctx) => {
148
if (ctx.isGroup) return undefined;
149
const person = RESOURCE_META[ctx.resource.id];
150
return (
151
<span class="flex min-w-0 items-center gap-2">
152
<Avatar class="size-5">
153
<AvatarImage src={person?.avatar} alt={ctx.resource.title} />
154
<AvatarFallback class="text-[10px]">
155
{person?.initials ?? ctx.resource.title.charAt(0)}
156
</AvatarFallback>
157
</Avatar>
158
<span class="truncate">{ctx.resource.title}</span>
159
</span>
160
);
161
}}
162
class="h-[440px] w-full"
163
>
164
<div class="flex flex-wrap items-center gap-2 border-b pe-3">
165
<GanttNav class="min-w-0 flex-1 border-b-0" />
166
<GanttToolbar>
167
<Button variant="outline" size="sm" onClick={addAssignment}>
168
<Plus class="size-4" aria-hidden="true" />
169
Add assignment
170
</Button>
171
</GanttToolbar>
172
</div>
173
<GanttView />
174
</Gantt>
175
</CardContent>
176
</Card>
177
</div>
178
);
179
}

A people-centric weekly view. Each row is a teammate rendered with an avatar label via renderResourceLabel, their bars are the week's assignments, and weekends plus one company holiday are shaded through an offDays config object with its own marker class. The toolbar drops a fresh assignment onto the next teammate, so one person can hold several bars at once; rowAlign="center" keeps each label centered as their row grows extra lanes.

Project Status Report

August 2026
Resources
Owner
Status
Planning
Requirements
ALAda Lovelace
Done
Design
GHGrace Hopper
Done
Build
Frontend
ATAlan Turing
In progress
Backend
LTLinus Torvalds
In progress
Launch
QA & Testing
KJKatherine Johnson
Not started
Rollout
MHMargaret Hamilton
Not started
W31 Jul 26 - 1
W32 Aug 2 - 8
W33 Aug 9 - 15
W34 Aug 16 - 22
W35 Aug 23 - 29
W36 Aug 30 - 5
Sat 1
Sun 2
Mon 3
Tue 4
Wed 5
Thu 6
Fri 7
Sat 8
Sun 9
Mon 10
Tue 11
Wed 12
Thu 13
Fri 14
Sat 15
Sun 16
Mon 17
Tue 18
Wed 19
Thu 20
Fri 21
Sat 22
Sun 23
Mon 24
Tue 25
Wed 26
Thu 27
Fri 28
Sat 29
Sun 30
Mon 31
100%
53%
0%
1
import { addDays, startOfDay, startOfWeek } from "date-fns";
2
import { Plus, SlidersHorizontal } from "lucide-solid";
3
import { createSignal, For } from "solid-js";
4
import {
5
Gantt,
6
type GanttApi,
7
type GanttColumn,
8
type GanttEvent,
9
GanttNav,
10
type GanttResource,
11
GanttToolbar,
12
GanttView,
13
} from "@/registry/kobalte/blocks/gantt";
14
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
15
import { Badge } from "~/components/ui/badge";
16
import { Button } from "~/components/ui/button";
17
import { Card, CardContent } from "~/components/ui/card";
18
import {
19
DropdownMenu,
20
DropdownMenuCheckboxItem,
21
DropdownMenuContent,
22
DropdownMenuGroup,
23
DropdownMenuLabel,
24
DropdownMenuTrigger,
25
} from "~/components/ui/dropdown-menu";
26
27
type TaskMeta = { owner: string; status: string };
28
29
/** Owner headshots keyed by name. Missing entries (e.g. "Unassigned") fall
30
* back to initials, and the initials also show while the photo loads. */
31
const OWNER_AVATARS: Record<string, string> = {
32
"Ada Lovelace": "https://randomuser.me/api/portraits/women/44.jpg",
33
"Grace Hopper": "https://randomuser.me/api/portraits/women/68.jpg",
34
"Alan Turing": "https://randomuser.me/api/portraits/men/32.jpg",
35
"Linus Torvalds": "https://randomuser.me/api/portraits/men/54.jpg",
36
"Katherine Johnson": "https://randomuser.me/api/portraits/women/90.jpg",
37
"Margaret Hamilton": "https://randomuser.me/api/portraits/women/12.jpg",
38
};
39
40
/** First + last initial from an owner name, used as the avatar fallback -
41
* "Ada Lovelace" reads "AL", "Unassigned" reads "UN". */
42
function ownerInitials(name: string) {
43
const parts = name.trim().split(/\s+/).filter(Boolean);
44
if (parts.length === 0) return "?";
45
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
46
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
47
}
48
49
/** Status label -> badge tint (green done, amber in progress, neutral not
50
* started). Zaidan's Badge has no coloured "light" variants, so the tint is
51
* raw Tailwind on top of the secondary variant. */
52
const STATUS_CLASS: Record<string, string> = {
53
Done: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
54
"In progress": "bg-amber-500/15 text-amber-700 dark:text-amber-400",
55
"Not started": "",
56
};
57
58
/**
59
* Status report: the left tree panel doubles as a task table with Owner and
60
* Status columns beside each phase, every bar carries a progress fill, and the
61
* timeline is drag-locked. New tasks are appended as rows from the toolbar.
62
*/
63
const INITIAL_RESOURCES: GanttResource[] = [
64
{
65
id: "planning",
66
title: "Planning",
67
children: [
68
{ id: "requirements", title: "Requirements" },
69
{ id: "design-phase", title: "Design" },
70
],
71
},
72
{
73
id: "build",
74
title: "Build",
75
children: [
76
{ id: "frontend", title: "Frontend" },
77
{ id: "backend", title: "Backend" },
78
],
79
},
80
{
81
id: "launch",
82
title: "Launch",
83
children: [
84
{ id: "qa", title: "QA & Testing" },
85
{ id: "rollout", title: "Rollout" },
86
],
87
},
88
];
89
90
/** Owner + status per task, keyed by resource.id - GanttResource has no room
91
* for custom fields, so the extra column data lives in a lookup of your own. */
92
const INITIAL_META: Record<string, TaskMeta> = {
93
requirements: { owner: "Ada Lovelace", status: "Done" },
94
"design-phase": { owner: "Grace Hopper", status: "Done" },
95
frontend: { owner: "Alan Turing", status: "In progress" },
96
backend: { owner: "Linus Torvalds", status: "In progress" },
97
qa: { owner: "Katherine Johnson", status: "Not started" },
98
rollout: { owner: "Margaret Hamilton", status: "Not started" },
99
};
100
101
/** Status-report fixture - progress descends from finished planning to
102
* not-started launch, and the phases straddle today so the now-line falls
103
* mid-plan. Kept inside a month so every phase reads at a glance. */
104
function buildBars(anchor: Date): GanttEvent[] {
105
const week = startOfWeek(startOfDay(anchor), { weekStartsOn: 0 });
106
const day = (dayOffset: number) => addDays(week, dayOffset);
107
const bar = (
108
resourceId: string,
109
title: string,
110
startOffset: number,
111
days: number,
112
color: string,
113
progress?: number,
114
): GanttEvent => ({
115
id: `bar-${resourceId}`,
116
title,
117
start: day(startOffset),
118
end: day(startOffset + days),
119
allDay: true,
120
color,
121
resourceId,
122
progress,
123
});
124
125
return [
126
bar("requirements", "Requirements", -10, 8, "var(--color-blue-500)", 100),
127
bar("design-phase", "Design", -8, 8, "var(--color-sky-500)", 100),
128
bar("frontend", "Frontend", -2, 10, "var(--color-violet-500)", 60),
129
bar("backend", "Backend", 0, 10, "var(--color-purple-500)", 45),
130
bar("qa", "QA & Testing", 8, 8, "var(--color-amber-500)", 0),
131
bar("rollout", "Rollout", 14, 5, "var(--color-emerald-500)", 0),
132
];
133
}
134
135
/** How many tasks the tree-foot "Add task" hint may append before it hides. */
136
const CREATE_TASK_LIMIT = 4;
137
138
export default function GanttStatusReport() {
139
const bars = buildBars(new Date());
140
let api: GanttApi | undefined;
141
const [resources, setResources] = createSignal<GanttResource[]>(INITIAL_RESOURCES);
142
const [meta, setMeta] = createSignal<Record<string, TaskMeta>>(INITIAL_META);
143
// Numbers each appended task so ids stay unique. A signal, because
144
// `canCreateTask` reads it to retire the create hint at the limit.
145
const [added, setAdded] = createSignal(0);
146
const [hiddenColumns, setHiddenColumns] = createSignal<string[]>([]);
147
148
// Extra tree-panel columns after the pinned name column. The definitions
149
// never change - each `render` reads `meta()` where it is called, so
150
// appended rows pick up their Owner and Status with no new array; group
151
// rows return null.
152
const columns: GanttColumn[] = [
153
{
154
id: "owner",
155
title: "Owner",
156
width: 130,
157
align: "start",
158
render: (ctx) => {
159
if (ctx.isGroup) return null;
160
const owner = meta()[ctx.resource.id]?.owner;
161
if (!owner) return null;
162
return (
163
<span class="flex min-w-0 items-center gap-2">
164
<Avatar class="size-5 shrink-0">
165
<AvatarImage src={OWNER_AVATARS[owner]} alt={owner} />
166
<AvatarFallback class="text-[10px]">{ownerInitials(owner)}</AvatarFallback>
167
</Avatar>
168
<span class="truncate">{owner}</span>
169
</span>
170
);
171
},
172
},
173
{
174
id: "status",
175
title: "Status",
176
width: 100,
177
align: "start",
178
render: (ctx) => {
179
if (ctx.isGroup) return null;
180
const status = meta()[ctx.resource.id]?.status;
181
if (!status) return null;
182
return (
183
<Badge variant="secondary" class={STATUS_CLASS[status]}>
184
{status}
185
</Badge>
186
);
187
},
188
},
189
];
190
191
/** The columns the menu currently leaves visible. */
192
const visibleColumns = () => columns.filter((column) => !hiddenColumns().includes(column.id));
193
194
const toggleColumn = (id: string, visible: boolean) =>
195
setHiddenColumns((prev) => (visible ? prev.filter((entry) => entry !== id) : [...prev, id]));
196
197
// Append a new task, register its Owner/Status, and drop a not-started bar
198
// on its row. `parent` is the phase it lands under, or null for a row of
199
// its own at the top level.
200
const addTask = (parent: string | null) => {
201
if (!api) return;
202
const n = added() + 1;
203
setAdded(n);
204
const id = `task-${n}`;
205
const node: GanttResource = { id, title: `New task ${n}` };
206
setResources((prev) =>
207
parent === null
208
? [...prev, node]
209
: prev.map((group) =>
210
group.id === parent ? { ...group, children: [...(group.children ?? []), node] } : group,
211
),
212
);
213
setMeta((prev) => ({ ...prev, [id]: { owner: "Unassigned", status: "Not started" } }));
214
const week = startOfWeek(startOfDay(new Date()), { weekStartsOn: 0 });
215
const start = addDays(week, (n % 6) - 2);
216
api.addEvent({
217
id: `bar-${id}`,
218
title: `New task ${n}`,
219
start,
220
end: addDays(start, 5),
221
allDay: true,
222
color: "var(--color-slate-400)",
223
resourceId: id,
224
progress: 0,
225
});
226
};
227
228
return (
229
<div class="w-full p-4">
230
<Card class="w-full py-0">
231
<CardContent class="p-0">
232
<Gantt
233
defaultEvents={bars}
234
resources={resources()}
235
defaultScale="month"
236
apiRef={(instance) => {
237
api = instance;
238
}}
239
// Read-only timeline: drag, resize and slot-select are off so the
240
// plan can't be shifted by dragging; rows are added via the toolbar.
241
defaultInteractions={{ drag: false, resize: false, selectSlot: false }}
242
columns={visibleColumns()}
243
// Pinned at the end of the tree header - the intended home for a
244
// columns dropdown.
245
columnsMenu={
246
<DropdownMenu placement="bottom-end">
247
<DropdownMenuTrigger
248
as={Button}
249
variant="ghost"
250
size="icon-sm"
251
aria-label="Toggle columns"
252
>
253
<SlidersHorizontal aria-hidden="true" />
254
</DropdownMenuTrigger>
255
<DropdownMenuContent class="w-40">
256
{/* Kobalte's label is a group label: it throws outside a
257
DropdownMenuGroup. */}
258
<DropdownMenuGroup>
259
<DropdownMenuLabel>Columns</DropdownMenuLabel>
260
<For each={columns}>
261
{(column) => (
262
<DropdownMenuCheckboxItem
263
checked={!hiddenColumns().includes(column.id)}
264
onChange={(checked) => toggleColumn(column.id, checked)}
265
>
266
{column.title}
267
</DropdownMenuCheckboxItem>
268
)}
269
</For>
270
</DropdownMenuGroup>
271
</DropdownMenuContent>
272
</DropdownMenu>
273
}
274
// The tree foot offers root-level creation only, so the hint files
275
// its task as its own top-level row; `canCreateTask` retires the
276
// affordance once the report has enough of them.
277
displayCreateTaskHint
278
canCreateTask={() => added() < CREATE_TASK_LIMIT}
279
onCreateTask={() => addTask(null)}
280
// Wider tree with a tighter name column so the Owner avatar,
281
// owner name and Status all fit alongside the task names.
282
treePanel={{ width: 400, nameColumnWidth: 150 }}
283
class="h-[500px] w-full"
284
>
285
<div class="flex flex-wrap items-center gap-2 border-b pe-3">
286
<GanttNav class="min-w-0 flex-1 border-b-0" />
287
<GanttToolbar>
288
<Button variant="outline" size="sm" onClick={() => addTask("launch")}>
289
<Plus class="size-4" aria-hidden="true" />
290
Add task
291
</Button>
292
</GanttToolbar>
293
</div>
294
<GanttView />
295
</Gantt>
296
</CardContent>
297
</Card>
298
</div>
299
);
300
}

A report on the month scale. Owner and Status columns sit beside each phase in the tree panel via the columns prop, a columnsMenu dropdown pinned to the tree header toggles them, and every bar carries a progress fill. Drag, resize, and slot-select are disabled so the plan can't be shifted by dragging, while the toolbar and the tree-foot "Add task" hint (displayCreateTaskHint + onCreateTask, gated by canCreateTask) append new tasks as their own rows with controlled resources.

API Reference

Gantt

The root provider and container. It creates (or adopts) the calendar instance, provides it through context, and renders a div shell with an aria-live announcer. Besides the props below, it accepts every state option (see State options), every callback (see Callbacks and validators), and every view configuration key (see View configuration) as flat props, plus the remaining div attributes.

PropTypeDefaultDescription
calendarGanttInstance<TData>-Adopt a hoisted useGanttState instance; option props are then ignored.
apiRef(api: GanttApi<TData>) => void-Called once on mount with the imperative api.
classstring-Additional CSS classes for the root container.
childrenJSX.Element-The gantt composition (GanttNav, GanttToolbar, GanttView).

The instance is captured once at setup: pass calendar from the first render on, or not at all. Swapping it later is unsupported. onSelectionChange on the root is always the gantt callback, never the DOM selectionchange handler.


GanttNav

The composed navigation bar: Today, scale switcher, prev/next, and the period title with a trailing spacer. Pass children to use it as a pure layout shell instead. The title follows the viewport center while scrolling so the header always names what you are looking at.

PropTypeDefaultDescription
childrenJSX.Element-Custom nav content; replaces the default composition.
classstring-Additional CSS classes.

GanttNavToday

Button that navigates to today. Renders the today i18n label by default and marks itself with data-active while the anchor period contains now.

PropTypeDefaultDescription
childrenJSX.Elementi18n todayCustom button content.
tooltipJSX.Element | nullthe current dateHover/focus-visible tooltip; null disables it. Never re-triggers from a pointer click.
classstring-Additional CSS classes.

GanttNavPrev

Icon button that steps the anchor date one period back at the current scale.

PropTypeDefaultDescription
childrenJSX.Elementchevron iconCustom button content.
tooltipJSX.Element | nulli18n previousHover/focus-visible tooltip; null disables.
classstring-Additional CSS classes.

GanttNavNext

Icon button that steps the anchor date one period forward at the current scale.

PropTypeDefaultDescription
childrenJSX.Elementchevron iconCustom button content.
tooltipJSX.Element | nulli18n nextHover/focus-visible tooltip; null disables.
classstring-Additional CSS classes.

GanttTitle

The current period title, formatted by i18n.functions.formatTitle and announced politely on change.

PropTypeDefaultDescription
format(ctx: { title: string }) => JSX.Element-Wraps or replaces the formatted title text.
classstring-Additional CSS classes.

GanttScaleSwitcher

Dropdown that switches between the Day, Week, Month, Quarter, and Year scales. Tooltips on this overlay-opener are hover-only so nothing flashes when focus returns after the menu closes.

PropTypeDefaultDescription
scalesGanttScale[]all fiveThe offered scales, in menu order.
childrenJSX.Elementcurrent scale labelCustom trigger content.
tooltipJSX.Element | nulli18n selectViewHover-only tooltip; null disables.
classstring-Additional CSS classes.

GanttDatePicker

Compact go-to-date picker (the Zaidan Calendar in a popover). Not part of the default GanttNav composition; add it to a custom nav when needed. It has no tooltip by design because it opens an overlay.

PropTypeDefaultDescription
classstring-Additional CSS classes.

GanttToolbar

Free slot for consumer toolbar buttons; a pure layout shell that also picks up classNames.toolbar from the view configuration.

PropTypeDefaultDescription
childrenJSX.Element-Toolbar content.
classstring-Additional CSS classes.

GanttView

The gantt body: split resizable tree and timeline panes with synced scrolling, the grouped two-row header, lanes, bars, summary rollups, off-screen chips, the zoom control, and all pointer interactions. Display behavior comes from the view configuration on the root.

PropTypeDefaultDescription
intervalnumberinterval configDay-scale unit interval in minutes.
classstring-Additional CSS classes.

GanttBar

The one interactive bar element, rendered by the view for every visible segment. The wrapper owns positioning hooks, a11y, selection, drag and resize listeners, the range tooltip, the optional right-click menu, and data attributes (data-selected, data-dragging, data-progress, data-completed, data-past, data-recurring); content comes from children, the root renderEvent override, or the built-in default. Exported for fully custom view compositions.

PropTypeDefaultDescription
segmentGanttSegment<TData>-Required. The timeline segment this bar renders.
childrenJSX.Element-Replaces the default bar content; the interactive wrapper stays gantt-owned.
labelOutsideboolean-The title renders beside the bar (view-owned), so the default inner content is suppressed.
rowTitlestring-The owning row's title for the aria-label; omitting falls back to a tree lookup.
classstring-Additional CSS classes.

GanttBar calls your onClick, onPointerDown, and onDblClick after its own handler, and spreads the remaining props last.


GanttEvent

One schedulable bar. TData is a fully generic consumer payload.

PropertyTypeDefaultDescription
idstring-Required. Stable event id.
titlestring-Required. Bar title.
startDate-Required. Plain instant; consumers parse ISO strings themselves.
endDate-Required. Exclusive; must be greater than or equal to start.
allDayboolean-Whether the event is date-based rather than timed.
recurrenceGanttRecurrenceRule | string-Structured rule or a raw "RRULE:..." line.
recurringEventIdstring-This event is an edited single occurrence of that series.
originalStartDate-Which occurrence it replaces (RECURRENCE-ID semantics).
colorstring-Token or CSS color; flows to the --gantt-event-color CSS variable.
readOnlyboolean-Excluded from drag and resize regardless of interactions state.
draggableboolean-Per-event override; default comes from interactions.drag.
resizableboolean-Per-event override; default comes from interactions.resize.
prioritynumber-Packing prominence; feeds getEventPriority ordering.
progressnumber-Completion 0-100; renders as a subtle fill inside the bar.
zIndexnumber-Explicit stacking override; wins over the computed z.
resourceIdstring-Resource row this bar belongs to.
dataTData-Consumer payload, fully generic.

GanttResource

A node of the gantt tree (task, person, equipment). Nesting via children renders as collapsible groups. GanttNode is the preferred alias for the same type.

PropertyTypeDefaultDescription
idstring-Required. Stable node id.
titlestring-Required. Row title.
colorstring-Token or CSS color used for subtle row accents.
scheduleModeGanttScheduleMode-Per-node cardinality override ("single"/"multiple").
childrenGanttResource[]-Child rows; presence makes this row a group.

GanttOccurrence

One expanded instance of an event within the visible range (recurring events expand to many).

PropertyTypeDescription
keystringStable per instance: `${event.id}::${startISO}`.
eventIdstringThe source event id.
eventGanttEvent<TData>The source event.
startDateOccurrence start.
endDateOccurrence end (exclusive).
allDaybooleanWhether the occurrence is date-based.
isRecurringbooleanWhether it came from a recurrence expansion.
recurrenceIndexnumberIndex within the series, when recurring.

GanttSegment

The slice of an occurrence rendered inside one timeline range, with lane packing metadata. Passed to GanttBar and every render override.

PropertyTypeDescription
occurrenceGanttOccurrence<TData>The occurrence this segment slices.
dayDateRange-start reference instant of the segment's slice.
isStartbooleanWhether the segment contains the occurrence start.
isEndbooleanWhether the segment contains the occurrence end.
continuesBeforebooleanThe occurrence continues before this segment.
continuesAfterbooleanThe occurrence continues after this segment.
startMinnumberMinutes from the visible range start, clamped to the range.
endMinnumberMinutes from the visible range start, clamped to the range.
columnnumberLane assigned by overlap packing.
columnCountnumberTotal lanes in the overlap cluster.
columnSpannumberLanes this segment may widen into.

GanttRecurrenceRule

Structured RFC 5545 subset. The built-in expander supports freq daily/weekly/monthly/yearly, interval, count, until, and weekly byWeekday without ordinals; byMonthDay, byMonth, and byWeekday outside weekly parse but throw a GanttRecurrenceError on expansion instead of silently mis-expanding. Plug the getOccurrences option for a full engine.

PropertyTypeDefaultDescription
freq"daily" | "weekly" | "monthly" | "yearly"-Required. Recurrence frequency.
intervalnumber1Period multiplier.
countnumber-Total occurrence cap.
untilDate-Inclusive series end instant.
byWeekdayArray<GanttWeekday | { day: GanttWeekday; ordinal: number }>-Weekday filter (weekly only, no ordinals).
byMonthDaynumber[]-Parsed but not expanded (throws on expansion).
byMonthnumber[]-Parsed but not expanded (throws on expansion).
weekStartGanttWeekday-WKST; parses and round-trips.
exDatesDate[]-Excluded instants; matching occurrences are removed (they still consume their count slot).
rDatesDate[]-Extra instants added to the series, each with the event's own duration.

GanttWeekday is "MO" \| "TU" \| "WE" \| "TH" \| "FR" \| "SA" \| "SU".


GanttProposedUpdate

The proposal handed to onEventUpdate and canDropEvent for every timing change.

PropertyTypeDescription
eventGanttEvent<TData>The event being updated.
occurrenceGanttOccurrence<TData> | nullThe gestured occurrence; null when source is "api".
startDateProposed start.
endDateProposed end.
allDaybooleanProposed all-day flag.
resourceIdstringThe bar's own resource (moves stay in-row); set on create/api.
source"drag" | "resize-start" | "resize-end" | "keyboard" | "api"What produced the proposal.

GanttUpdateResult

Return type of onEventUpdate: false rejects and reverts; void or true accepts; { start?: Date; end?: Date; allDay?: boolean } accepts with an adjustment.


GanttSlotInfo

Payload of onSlotClick. A click is a point, not a range; end is reserved for future gestures.

PropertyTypeDescription
dateDateThe clicked instant.
endDateReserved for future gestures.
allDaybooleanWhether the click landed on a date-based surface.
resourceIdstringPresent when the click happened inside a resource row.

GanttSlotDraft

The in-progress drag-create rectangle only, cleared on commit or cancel; the committed slot selection lives in GanttSelection.slot. Payload of onSelectSlot and canSelectSlot.

PropertyTypeDescription
startDateDraft start.
endDateDraft end.
allDaybooleanWhether the draft is date-based.
resourceIdstringPresent when the slot was selected inside a resource row.

GanttSelection

The committed selection state.

PropertyTypeDescription
eventKeysstring[]Selected occurrence keys.
slot{ start: Date; end: Date; allDay: boolean } | nullCommitted slot selection (drafts live in GanttSlotDraft).

GanttResourceReorder

Proposal emitted when a timeline resource row is drag-reordered. Payload of onResourceReorder, canReorderResource, and onResourceReorderReject.

PropertyTypeDescription
resourceIdstringThe dragged resource id.
parentIdstring | nullNew parent id, or null for the root level.
indexnumberInsertion index among the new parent's children.
resourcesGanttResource[]The full resource tree with the move applied (convenience).

GanttRangeInfo

Payload of onRangeChange; fires once on mount and whenever the rendered range changes. Fetch remote data for range.

PropertyTypeDescription
rangeGanttDateRangeFull rendered axis range.
activeRangeGanttDateRangeThe logical period (the month/week itself).
scaleGanttScaleCurrent scale.
dateDateAnchor date.
timeZonestringDisplay time zone.

GanttDateRange is { start: Date; end: Date } with an inclusive start and exclusive end. GanttScale is "day" \| "week" \| "month" \| "quarter" \| "year".


GanttState

The full engine snapshot returned by instance.getState(). The object identity is stable and every property is a reactive read, so instance.getState().scale tracks inside a createMemo, a createEffect, or JSX; useGanttSelector narrows it to one derived accessor.

PropertyTypeDescription
scaleGanttScaleHorizontal axis scale.
dateDateAnchor date.
visibleRangeGanttDateRangeFull rendered axis range - fetch remote data for this.
activeRangeGanttDateRangeThe logical period (the month/week itself).
eventsGanttEvent<TData>[]Current events.
selectionGanttSelectionCommitted selection.
interactionsGanttInteractionsEffective interaction switches.
loadingbooleanMirrors the loading option.
dragGanttDragState<TData> | nullIn-flight drag/resize gesture, or null.
slotDraftGanttSlotDraft | nullIn-flight drag-create rectangle, or null.
viewportCenterDate | nullInstant at the center of the scrolled viewport; the nav title follows it.

GanttDragState

The in-flight gesture stored in state.drag.

PropertyTypeDescription
kind"move" | "resize-start" | "resize-end"Gesture kind.
occurrenceGanttOccurrence<TData>The gestured occurrence.
proposedStartDateCurrent snapped proposal start.
proposedEndDateCurrent snapped proposal end.
proposedAllDaybooleanCurrent proposal all-day flag.
proposedResourceIdstringThe bar's own resource; moves are x-axis only and never cross rows.
validbooleanLast canDropEvent verdict; drives data-drop-invalid styling.

GanttDataAdapter

External-data contract: getEvents(range, signal?) => Promise<GanttEvent<TData>[]>. The type ships for adapter recipes (Google events.list and MS Graph calendarView map to GanttEvent in about 15 lines); OAuth, tokens, and sync loops are application backend territory.


GanttRenderEventProps

Payload of renderEvent and renderEventMenu.

PropertyTypeDescription
occurrenceGanttOccurrence<TData>The bar's occurrence.
segmentGanttSegment<TData>The rendered segment.
isDraggingbooleanWhether a gesture owns this bar.
isSelectedbooleanWhether the bar is selected.

GanttColumnContext

Row context handed to tree-panel column renderers, renderResourceLabel, renderResourceMenu, and the resource click callbacks.

PropertyTypeDescription
resourceGanttResourceThe row's resource.
depthnumberNesting depth (root = 0).
isGroupbooleanWhether the row has children.
collapsedbooleanWhether the group is currently collapsed.

GanttDragIndicatorProps

Live gesture snapshot handed to renderDragPreview and renderResizeIndicator; the content re-renders per snap step while the gantt writes the wrapper position imperatively.

PropertyTypeDescription
occurrenceGanttOccurrence<TData>The gestured occurrence.
kind"move" | "resize-start" | "resize-end"Gesture kind.
startDateProposed (snapped) start of the current step.
endDateProposed (snapped) end of the current step.
validbooleanLast canDropEvent verdict.

GanttScheduleHintProps

Slot handed to a custom renderScheduleHint renderer.

PropertyTypeDescription
startDateSnapped hint start.
endDateSnapped hint end.
resourceGanttResourceThe hovered row's resource.

GanttSummaryProps

Parent rollup handed to a custom renderSummary renderer.

PropertyTypeDescription
resourceGanttResourceThe group row's resource.
startDateEnvelope start of the descendant bars.
endDateEnvelope end of the descendant bars.
progressnumber | nullDuration-weighted progress, or null to hide.

GanttApi

The imperative surface, available as instance.api from useGantt/useGanttState or through the root apiRef callback.

MethodSignatureDescription
next() => voidStep one period forward (clamped to rangeBounds).
prev() => voidStep one period back (clamped to rangeBounds).
today() => voidJump to today.
goTo(date: Date) => voidJump to a date.
setScale(scale: GanttScale) => voidSwitch the axis scale.
getEvents() => GanttEvent<TData>[]Current events.
getEvent(id: string) => GanttEvent<TData> | undefinedFind one event by id.
setEvents(events: GanttEvent<TData>[]) => voidReplace all events.
addEvent(event: GanttEvent<TData>) => voidAppend an event.
updateEvent(id: string, patch: Partial<GanttEvent<TData>>) => voidPatch an event; timing changes route through onEventUpdate with source "api".
removeEvent(id: string) => voidRemove an event.
getOccurrences(range?: GanttDateRange) => GanttOccurrence<TData>[]Expanded, sorted occurrences; defaults to the visible range.
findOverlapping(candidate: { start: Date; end: Date; excludeEventId?: string }) => GanttOccurrence<TData>[]Occurrences overlapping a candidate range.
select(selection: Partial<GanttSelection>) => voidMerge into the selection.
selectEvent(key: string, opts?: { additive?: boolean }) => voidSelect one occurrence key; additive toggles.
clearSelection() => voidClear the selection.
setInteractions(patch: Partial<GanttInteractions>) => voidPatch the interaction switches.
getVisibleRange() => GanttDateRangeFull rendered axis range.
getActiveRange() => GanttDateRangeThe logical period range.
toZoned(date: Date) => DateTZDate in the gantt's display time zone.

Hooks

Most hooks must run under a <Gantt> ancestor. The exceptions: useGanttState creates the instance itself, useGanttSelector accepts an explicit instance, and useGanttViewConfig falls back to the default view configuration outside the tree.

Every hook that exposes a live value returns an Accessor — call it. useGanttSettings() and useGanttViewConfig() return objects of reactive getters instead; read them by property (settings.timeZone, viewConfig.scheduleMode) and never destructure them into locals, which would freeze the value.

HookSignatureDescription
useGanttState(options?: UseGanttStateOptions<TData>) => GanttInstance<TData>Headless root hook: the full engine without any markup. Pass the instance to <Gantt calendar={...}> or drive fully custom UI.
useGantt() => GanttInstance<TData>The stable calendar instance; throws outside <Gantt>.
useGanttSelector(selector: (state: GanttState<TData>) => T, options?: { calendar?, isEqual? }) => Accessor<T>Derived accessor over the engine state, with equality memoization (Object.is default).
useGanttScale() => { scale: Accessor<GanttScale>; setScale }Current scale and setter.
useGanttNavigation() => { date, title, visibleRange, activeRange, isToday: Accessor<...>; next, prev, today, goTo }Navigation state (accessors) and actions; title follows the viewport center.
useGanttSelection() => { selection: Accessor<GanttSelection>; select, selectEvent, clearSelection }Selection state and actions.
useGanttInteractions() => { interactions: Accessor<GanttInteractions>; setInteractions }Interaction switches and setter.
useGanttOccurrences(range?: GanttDateRange) => Accessor<GanttOccurrence<TData>[]>Expanded, sorted occurrences; defaults to the visible range.
useGanttNodeSchedules(nodeId: string | Accessor<string>) => { node, scheduleMode, schedules, conflicts }One node's schedules as accessors: the node, its resolved cardinality, its occurrences in order, and the pairs that collide.
useGanttSettings() => GanttSettings<TData>Resolved settings including merged i18n, as reactive getters.
useGanttViewConfig() => GanttViewConfig<TData>Root-level display props and render overrides, for view components.
useGanttBarContext() => GanttBarContextValue<TData>The bar's subject (occurrence, segment, isDragging, isSelected); throws outside <GanttBar>.
useGanttGestures() => { beginMove, beginResize, beginCreate, canDrag, canResize }Per-bar and per-row pointer gesture wiring, for custom view compositions.

useGanttGestureTeardown() cancels any gesture this subtree owns on cleanup; call it from a custom view root.

Helpers

Pure, framework-free helpers exported from gantt-lib.ts, the gesture utilities from gantt-dnd.tsx, and mergeGanttI18n from gantt-i18n.ts — all re-exported from the block's index.tsx. gantt-lib.ts also exports the advanced types GanttIndex, BuildIndexOptions, PackOptions, GanttLaneMemo, ViewRangeOptions, ViewDateRanges, and WeekStartsOn used in these signatures, and gantt.tsx exports the GanttContext and GanttViewConfigContext context objects for advanced composition.

FunctionSignatureDescription
flattenResources(resources: GanttResource[], depth?: number) => Array<{ resource: GanttResource; depth: number }>Depth-first flatten of the resource tree (parents included).
findResource(resources: GanttResource[], id: string) => GanttResource | nullDepth-first lookup of one node.
reorderResources(resources, resourceId, parentId, index) => GanttResource[] | nullPure tree move; returns a new tree, or null for impossible moves.
resolveOffDay(day: Date, timeZone: string, config: boolean | GanttOffDaysConfig | undefined) => booleanResolves whether a day is an off day in the display zone.
getGanttDateRange(scale, date, opts: { timeZone, weekStartsOn }) => { visibleRange, activeRange }Axis range for the anchor date at the given scale.
stepGanttDate(scale, date, direction: 1 | -1, opts: { timeZone }) => DateThe anchor date stepped one period.
buildEventIndex(events, visibleRange, opts: { timeZone, eventOrder?, getOccurrences? }) => { occurrences }Expands and sorts all occurrences for a range.
defaultEventOrder(a: GanttOccurrence, b: GanttOccurrence) => numberStart ascending, longer first, then key.
packTimedSegments(segments: GanttSegment[], options?: PackOptions) => voidOverlap packing for one row's segments; mutates column/columnCount/columnSpan.
getLaneKey(occurrence: { eventId: string; recurrenceIndex?: number }) => stringPacking identity of one occurrence, for PackOptions.preferredLanes.
eventsOverlap(a: { start, end }, b: { start, end }) => booleanHalf-open range overlap test.
rangesIntersect(a: GanttDateRange, b: GanttDateRange) => booleanHalf-open range intersection test.
spansMultipleDays(occ: { start, end }) => booleanTrue past 24h (an event ending exactly at the next midnight is single-day).
getDayKey(date: Date, timeZone: string) => stringStable per-day key in the display time zone.
getDayTotalMinutes(dayStart: Date, timeZone: string) => numberDay length in minutes; 1380/1500 on DST transition days.
getRangeKey(range: GanttDateRange) => stringCheap cache key for a range.
snapMinutes(minutes: number, snap: number) => numberRounds minutes to the snap grid.
toZoned(date: Date, timeZone: string) => TZDateThe instant re-expressed in the display time zone.
zonedStartOfDay(date: Date, timeZone: string) => TZDateZoned midnight of the day containing the instant.
mergeGanttI18n(overrides?: GanttI18nOverrides) => GanttI18nConfigDeep-partial i18n merge; replaces individual keys, never sections.
wasRecentDrag() => booleanTrue within 250ms of a gesture end, so click handlers can ignore the click that ends a drag.
markGestureEnd() => voidMark a non-dnd gesture (e.g. a timeline pan) so the click it ends is ignored.
cancelActiveGanttGestures() => voidAbort every gesture currently in flight.

Exported constants: GANTT_SCALES (the five scales in menu order), GANTT_COLORS (ten named Tailwind palette presets for bar colors), GANTT_ACTIVATION (the default activation thresholds), MIN_PACK_SLOT (30, packing-effective minimum minutes), MAX_OCCURRENCES (1000, recurrence expansion cap per event), DEFAULT_GANTT_I18N (the default i18n config), DEFAULT_VIEW_CONFIG (the default view configuration), DEFAULT_SCHEDULE_MODE, and DEFAULT_ROW_ALIGN.


Recurrence

Exported from gantt-recurrence.ts. The built-in expander covers the RFC 5545 subset described under GanttRecurrenceRule; unsupported parts throw GanttRecurrenceError instead of silently mis-expanding, and expansion caps at MAX_OCCURRENCES (1000) per event.

FunctionSignatureDescription
parseRRuleString(input: string, timeZone?: string) => GanttRecurrenceRuleParses a raw RRULE line (with or without the RRULE: prefix); floating UNTIL resolves in the display zone.
formatRRuleString(rule: GanttRecurrenceRule) => stringSerializes the structured subset back to an RRULE line (without prefix).
expandRecurrence(event: GanttEvent<TData>, range: GanttDateRange, ctx: { timeZone: string }) => GanttOccurrence<TData>[]Expands one event into its occurrences intersecting the range; DST-safe wall-time iteration.
GanttRecurrenceErrorclass extends ErrorThrown for unsupported or invalid rules; catch it to fall back or surface a message.

Config

State options

State and configuration options accepted by useGanttState and, as flat props, by <Gantt>. Every controlled prop has an uncontrolled default* twin.

PropTypeDefaultDescription
eventsGanttEvent<TData>[]-Controlled events; pairs with onEventsChange.
defaultEventsGanttEvent<TData>[][]Initial events (uncontrolled).
scaleGanttScale-Controlled scale; pairs with onScaleChange.
defaultScaleGanttScale"day"Initial scale (uncontrolled).
dateDate-Controlled anchor date; pairs with onDateChange.
defaultDateDatenew Date()Initial anchor date (uncontrolled).
selectionGanttSelection-Controlled selection; pairs with onSelectionChange.
defaultSelectionGanttSelectionemptyInitial selection (uncontrolled).
interactionsPartial<GanttInteractions>-Controlled interaction switches; pairs with onInteractionsChange.
defaultInteractionsPartial<GanttInteractions>all trueInitial interaction switches (uncontrolled).
loadingbooleanfalseLoading flag mirrored into state.
timeZonestringsystem zoneIANA display time zone.
localeLocale-date-fns locale for all formatting.
weekStartsOn0 | 1 | 2 | 3 | 4 | 5 | 6locale's, else 0First day of the week; defaults from locale when one is set.
slotDurationnumber30Minimum created-slot length in minutes for a bare create click.
snapDurationnumber15Minute snapping on the day scale; other scales snap to whole zoned days.
i18nGanttI18nOverrides-Deep-partial label/format/function overrides (see Internationalization).
rangeBounds{ min?: Date; max?: Date }-Hard travel bounds for navigation and infinite scrolling; either side may be omitted.
activationGanttActivationConfig-Pointer-activation threshold overrides for drag/resize/create.
maxRangeWindownumber12Infinite-scroll growth cap in whole periods per side; past it the anchor slides instead.
resourcesGanttResource[][]Tree nodes of the gantt.
overlap"allow" | "clamp" | "reject""allow"What a gesture may do when it would overlap another schedule in the same node. Policy only.
getEventPriority(event: GanttEvent<TData>) => numberevent.priority ?? 0Packing prominence per event.
eventOrder(a: GanttOccurrence<TData>, b: GanttOccurrence<TData>) => numberpriority-awareOccurrence sort; the default orders higher getEventPriority first, then start/duration/key.
getOccurrences(event, range, ctx: { timeZone: string }) => Array<{ start: Date; end: Date }> | null-Escape hatch for exotic recurrence: return the expanded occurrences yourself.

Callbacks and validators

All callbacks live beside the state options on useGanttState and <Gantt>.

PropTypeDescription
onEventClick(occurrence, e: MouseEvent) => voidBar click (the click that ends a drag is ignored).
onEventDoubleClick(occurrence, e: MouseEvent) => voidBar double click.
onEventUpdate(update: GanttProposedUpdate<TData>) => GanttUpdateResultCommit gate for every timing change; return false to reject, an object to adjust.
canDropEvent(update: GanttProposedUpdate<TData>) => booleanLive validity predicate while dragging or resizing; drives the destructive indicator.
onSlotClick(slot: GanttSlotInfo, e: MouseEvent) => voidClick on empty schedulable track (also the schedule-hint activation).
onSelectSlot(slot: GanttSlotDraft) => voidDrag-create commit.
canSelectSlot(slot: GanttSlotDraft) => booleanLive validity predicate for the drag-create rectangle.
onCreateTask(ctx: { parentId: string | null; index: number }) => voidFires when the "add task" hint is activated; create a new tree row.
canCreateTask(ctx: { parentId: string | null }) => booleanGates the "add task" hint; the shipped view offers root-level creation only (parentId = null).
onResourceClick(ctx: GanttColumnContext, e: MouseEvent) => voidClick on a tree row's surface (chevron/checkbox/grip clicks excluded).
onResourceDoubleClick(ctx: GanttColumnContext, e: MouseEvent) => voidDouble click on a tree row's surface.
onRangeChange(info: GanttRangeInfo) => voidRendered range changed (fires once on mount); fetch remote data here.
onScaleChange(scale: GanttScale) => voidScale changed.
onDateChange(date: Date) => voidAnchor date changed (navigation or an infinite-scroll anchor slide).
onSelectionChange(selection: GanttSelection) => voidSelection changed.
onInteractionsChange(interactions: GanttInteractions) => voidInteraction switches changed.
onEventsChange(events: GanttEvent<TData>[]) => voidEvents changed (accepted updates, api mutations).
onResourceReorder(proposal: GanttResourceReorder) => void | falseCommit gate for tree-row drag reorder; adopt proposal.resources into your resources state, return false to reject.
canReorderResource(proposal: GanttResourceReorder) => booleanLive validity predicate while a resource row is being dragged.
onResourceReorderReject(proposal: GanttResourceReorder) => voidFires when a reorder gesture is released on a rejected position; explain the rejection (a toast).

View configuration

Display props and render overrides. These live on <Gantt> as flat props (and GanttView accepts interval directly), never in the headless options; view components read them via useGanttViewConfig.

PropTypeDefaultDescription
nowIndicatorbooleantrueRed now-line on the axis.
intervalnumber60Day-scale unit interval in minutes; axis units and gridlines follow it.
scrollbars"custom" | "native""custom"Scroll implementation for the gantt body: the Zaidan ScrollArea or browser scrollbars.
displayScheduleHintbooleanfalsePlacement hint over empty timeline track: a validated, snapped tile that opens the schedule flow.
initialCenter"now" | "anchor" | Date"now"Where the viewport opens. "now" follows the wall clock; pass an instant for a composition that must not.
dragCreatebooleanfalseEmpty-track presses start a drag-create gesture committing through onSelectSlot; off means the panel drags-to-pan.
displayCreateTaskHintbooleanfalse"Add task" affordance at the foot of the tree; shown only when onCreateTask is set and canCreateTask allows it.
zoomControlbooleantrueFloating zoom in/out control over the track.
wheelZoombooleantrueCtrl/Cmd + wheel over the timeline zooms the range, anchored on the pointer; trackpad pinch arrives as the same event. At the zoom limits the gesture returns to the browser so page zoom still works.
navButtonVariant"ghost" | "outline" | "secondary" | "default""ghost"Nav button variant; all nav buttons follow it.
navButtonSize"sm" | "default""sm"Nav button size; icon buttons use the icon twin.
offDaysboolean | GanttOffDaysConfigtrueOff-day marking on day/week/month scales; true = weekends with a muted background, an object customizes it.
columnsGanttColumn[]-Extra tree-panel columns after the built-in name column; the panel scrolls horizontally, the name column stays pinned.
columnsMenuJSX.Element-Consumer slot pinned at the end of the tree-panel header, the intended home for a columns dropdown.
treePanelGanttTreePanelConfig-Tree-panel width, splitter bounds, and resizability.
timelineLinesGanttTimelineLines | "vertical" | "both" | "none""vertical"Timeline gridlines. The object form sets each axis independently ({ vertical: "dashed", horizontal: true }).
barLabel"inside" | "outside" | "auto""inside"Bar title placement; "auto" moves it outside only when the bar is too short.
offscreenIndicatorsbooleantrueEdge chips that scroll to bars outside the visible timeline.
infiniteScrollbooleantrueExtend the timeline into the past/future while scrolling near an edge (the anchor period stays the nav title).
zoomRange{ min?: number; max?: number; step?: number }0.5 - 3, step 0.25Zoom bounds and button step for the floating control.
metricsGanttMetrics-Layout metric overrides (row/lane/unit geometry, thresholds).
stickyNavbooleanfalseSticky nav bar.
rowCheckboxesbooleantrueLeaf-row selection checkboxes in the tree panel; uncontrolled unless selectedRows is passed.
selectedRowsstring[]-Controlled selected row ids; pairs with onSelectedRowsChange.
onSelectedRowsChange(ids: string[]) => void-Selected rows changed.
collapsedGroupsstring[]-Controlled collapsed group ids; pairs with onCollapsedGroupsChange.
defaultCollapsedGroupsstring[]-Initial collapsed group ids (uncontrolled).
onCollapsedGroupsChange(ids: string[]) => void-Collapsed groups changed.
zoomnumber-Controlled zoom multiplier; pairs with onZoomChange.
defaultZoomnumber1Initial zoom multiplier (uncontrolled).
onZoomChange(zoom: number) => void-Zoom changed.
parentSchedulingbooleanfalseAllow drag-create and slot clicks on rows that have children; off means parents aggregate their subtree.
summaryBarsbooleantrueRollup strips on parent rows without bars of their own: descendant envelope with duration-weighted progress.
scheduleMode"single" | "multiple""multiple"How many schedules a node may hold. "single" keeps one track per node; any node can override it.
rowAlign"start" | "center""start"Vertical placement of a row's content once a node holds several lanes.
classNamesGanttClassNames-Class overrides for nav, toolbar, view, and event.
renderEvent(props: GanttRenderEventProps<TData>) => JSX.Element-Replaces the default bar content; the interactive wrapper stays gantt-owned.
renderEventMenu(props: GanttRenderEventProps<TData>) => JSX.Element-Right-click menu for a bar: return ContextMenu items; omit for no menu.
renderResourceLabel(props: GanttColumnContext) => JSX.Element-Tree-node label; return any rich content (icons, badges). Default is the plain title.
renderResourceMenu(ctx: GanttColumnContext) => JSX.Element-Right-click menu for a tree row (same contract as renderEventMenu).
renderNoResources() => JSX.Element-Rendered in the timeline body when there are no resources.
renderDragPreview(props: GanttDragIndicatorProps<TData>) => JSX.Element-Replaces the smooth cursor-following move clone; the gantt owns the wrapper and positions it per pointermove.
renderResizeIndicator(props: GanttDragIndicatorProps<TData>) => JSX.Element-Replaces the resize edge line and status chip; same positioning contract as renderDragPreview.
renderScheduleHint(props: GanttScheduleHintProps) => JSX.Element-Replaces the schedule-hint tile and bubble inside the snapped, validated, pointer-transparent wrapper.
renderSummary(props: GanttSummaryProps) => JSX.Element-Replaces the parent rollup strip (the positioned wrapper stays gantt-owned).
getSummaryProgress(ctx: { resource: GanttResource; events: GanttEvent<TData>[] }) => number | null-Replaces the rollup math: return 0-100 (or null to hide). Default: duration-weighted mean progress.

GanttInteractions

Global interaction switches; per-event readOnly, draggable, and resizable override them.

PropertyTypeDefaultDescription
dragbooleantrueHorizontal move within the bar's own row; never across rows.
resizebooleantrueEdge resize on bar segments.
selectSlotbooleantrueSlot selection (drag-create and slot clicks).

GanttOffDaysConfig

Off-day (non-working day) marking. true uses the defaults: weekends with a muted background. Marked cells carry data-off for CSS-selector customization.

PropertyTypeDefaultDescription
weekendDaysnumber[][0, 6]Weekday numbers treated as off (0 = Sunday).
datesDate[]-Additional explicit off dates (compared by day in the display zone).
isOffDay(day: Date) => boolean-Full custom predicate; runs in addition to weekendDays/dates.
classstring"bg-muted/40"Marker classes; replaces both the header wash and the body hatch.

GanttTreePanelConfig

Left tree-panel sizing and splitter behavior.

PropertyTypeDefaultDescription
widthnumber288Initial panel width in px.
minWidthnumber180Splitter lower bound in px.
maxWidthnumber640Splitter upper bound in px.
resizablebooleantrueDrag/keyboard splitter between the panels.
nameColumnWidthnumber208Width of the sticky name column in px.
onWidthChange(width: number) => void-Fires after any user resize (drag release, keyboard, double-click reset).

GanttColumn

One extra tree-panel column after the built-in name column.

PropertyTypeDefaultDescription
idstring-Required. Stable id; doubles as the default header label.
titleJSX.Element-Header label.
widthnumber96Fixed column width in px.
align"start" | "center" | "end""start"Cell content alignment.
render(ctx: GanttColumnContext) => JSX.Element-Cell content per row; omit or return null for an empty cell.
classstring-Extra classes on every cell of this column (header included).

GanttMetrics

Layout metrics (rem unless noted); every knob falls back to its default.

PropertyTypeDefaultDescription
laneHeightnumber1.25Height of one schedule bar.
laneGapnumber0.1875Gap between stacked schedules in one node.
rowPaddingnumber0.5Vertical inset between a row's edges and its block of schedules.
minRowHeightnumber2.5Minimum row height.
autoLabelMinnumber7barLabel "auto" flips the title outside below this bar width.
unitWidthsPartial<Record<GanttScale, number>>-Unit width at zoom 1 per scale (day scale = width per interval unit).
minTimelineWidthnumber200Minimum timeline pane width in px.
infiniteScrollEdgenumber160Scroll distance (px) from an edge that grows the range.

GanttActivationConfig

Pointer-activation thresholds; unset keys keep the dnd-kit parity defaults.

PropertyTypeDefaultDescription
moveDistancePxnumber5Mouse travel (px) before a bar move starts.
createDistancePxnumber4Mouse travel (px) before a drag-create starts.
touchDelayMsnumber250Touch long-press delay in ms.
touchTolerancePxnumber5Touch movement tolerance (px) during the long-press.

GanttClassNames

Class overrides for the composed parts. This is the shadcn slot-map convention, not a React leftover: the individual components take class.

PropertyTypeDefaultDescription
navstring-Classes for GanttNav.
toolbarstring-Classes for GanttToolbar.
viewstring-Classes for the gantt body (tree + track).
eventstring-Classes for every bar.

Internationalization

The i18n option takes a GanttI18nOverrides object: a deep-partial of GanttI18nConfig where a partial override replaces individual keys, never whole sections (merged by mergeGanttI18n, defaults in DEFAULT_GANTT_I18N).

labels keys and defaults:

PropertyTypeDefault
todaystring"Today"
previousstring"Previous"
nextstring"Next"
addEventstring"Add event"
addTaskstring"Add task"
allDaystring"All day"
loadingstring"Loading events"
eventstring"event"
events(count: number) => string"1 event" / `${count} events`
week(weekNumber: number) => string`W${weekNumber}`
resourcesstring"Resources"
goToDatestring"Go to date"
scheduleHintstring"Click to add a schedule"
scheduleHintDragstring"Click or drag to add a schedule"
reorderstring"Reorder"
selectViewstring"Select view"
zoomInstring"Zoom in"
zoomOutstring"Zoom out"
resizePanelstring"Resize panel"
jumpToBar(title: string) => string`Scroll to "${title}"`
progress(percent: number) => string`${percent}% complete`
durationDays(days: number) => string"1 day" / `${days} days`
continuesstring"continues"
scalesRecord<GanttScale, string>Day, Week, Month, Quarter, Year

formats are date-fns format strings, applied with the gantt locale:

PropertyDefaultDescription
monthTitle"MMMM yyyy"Month-scale title.
dayTitle"EEEE, MMMM d, yyyy"Day-scale title and Today tooltip.
timeGutter"h a"Day-scale axis unit labels.
eventTime"h:mm a"Timed event time labels.

functions are the composed formatters. The defaults are re-bound to the merged labels/formats on every merge, so overriding a format string (for example formats.eventTime) reaches the default renderers without also replacing the function:

PropertySignatureDescription
formatTitle(scale, ctx: { date, activeRange, visibleRange, locale? }) => stringThe nav title per scale (week gets a smart range label).
formatEventTime(start: Date, end: Date, allDay: boolean, locale?: Locale) => stringBar time/range labels; all-day bars show their date range.
formatDayRange(range: GanttDateRange, locale?: Locale) => stringCompact day-range label.
formatEventAriaLabel(parts: { title, timeLabel, rowTitle?, progressLabel?, continues: boolean }) => stringComposes the bar screen-reader label; appends labels.continues when clipped.

On This Page

  • Installation
  • Usage
  • Examples
    • Annual Product Roadmap
    • Team Capacity Schedule
    • Project Status Report
  • API Reference
    • Gantt
    • GanttNav
    • GanttNavToday
    • GanttNavPrev
    • GanttNavNext
    • GanttTitle
    • GanttScaleSwitcher
    • GanttDatePicker
    • GanttToolbar
    • GanttView
    • GanttBar
    • GanttEvent
    • GanttResource
    • GanttOccurrence
    • GanttSegment
    • GanttRecurrenceRule
    • GanttProposedUpdate
    • GanttUpdateResult
    • GanttSlotInfo
    • GanttSlotDraft
    • GanttSelection
    • GanttResourceReorder
    • GanttRangeInfo
    • GanttState
    • GanttDragState
    • GanttDataAdapter
    • GanttRenderEventProps
    • GanttColumnContext
    • GanttDragIndicatorProps
    • GanttScheduleHintProps
    • GanttSummaryProps
    • GanttApi
  • Hooks
  • Helpers
    • Recurrence
  • Config
    • State options
    • Callbacks and validators
    • View configuration
    • GanttInteractions
    • GanttOffDaysConfig
    • GanttTreePanelConfig
    • GanttColumn
    • GanttMetrics
    • GanttActivationConfig
    • GanttClassNames
    • Internationalization
Built by Kevin Abatan. The source code is available on GitHub.