Gantt
import { addDays, type Locale, startOfDay, startOfWeek } from "date-fns";import { ar, de, es, fr, ja } from "date-fns/locale";import { RotateCcw, Settings } from "lucide-solid";import type { JSX } from "solid-js";import { createStore } from "solid-js/store";import { Gantt, type GanttApi, type GanttEvent, type GanttI18nOverrides, type GanttInteractions, GanttNav, type GanttResource, type GanttSlotDraft, GanttToolbar, GanttView,} from "@/registry/kobalte/blocks/gantt";import { Button } from "~/components/ui/button";import { Card, CardContent } from "~/components/ui/card";import { Label } from "~/components/ui/label";import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group";import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "~/components/ui/select";import { Switch } from "~/components/ui/switch";import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
/** * White-label task tree, tall enough that the panes scroll vertically. The * Launch group and "Release prep" ship UNSCHEDULED: hover their empty rows * and click the hint tile (or drag a range) to schedule them. */const RESOURCES: GanttResource[] = [ { id: "planning", title: "Planning", children: [ { id: "brief", title: "Project brief" }, { id: "scope", title: "Scope review" }, ], }, { id: "design", title: "Design", children: [ { id: "wireframes", title: "Wireframes" }, { id: "visual-design", title: "Visual design" }, ], }, { id: "build", title: "Build", children: [ { id: "frontend", title: "Frontend" }, { id: "backend", title: "Backend" }, { id: "qa", title: "QA pass" }, { id: "release-prep", title: "Release prep" }, ], }, { id: "launch", title: "Launch", children: [ { id: "docs", title: "Docs" }, { id: "marketing-site", title: "Marketing site" }, { id: "announcement", title: "Announcement" }, ], },];
/** Leaf id -> title, for naming bars scheduled from empty rows. */const RESOURCE_TITLES = new Map( RESOURCES.flatMap((group) => group.children ?? []).map((leaf) => [leaf.id, leaf.title]),);
/** Small white-label fixture built around the current week. */function buildBars(anchor: Date): GanttEvent[] { const week = startOfWeek(startOfDay(anchor), { weekStartsOn: 0 }); const day = (dayOffset: number) => addDays(week, dayOffset); const bar = ( resourceId: string, title: string, startOffset: number, days: number, color: string, progress?: number, ): GanttEvent => ({ id: `bar-${resourceId}`, title, start: day(startOffset), end: day(startOffset + days), allDay: true, color, resourceId, progress, });
return [ bar("brief", "Project brief", -9, 3, "var(--color-blue-500)", 100), bar("scope", "Scope review", -6, 2, "var(--color-sky-500)", 100), bar("wireframes", "Wireframes", -4, 4, "var(--color-violet-500)", 80), bar("visual-design", "Visual design", 0, 5, "var(--color-purple-500)", 35), bar("frontend", "Frontend", 3, 7, "var(--color-emerald-500)", 10), bar("backend", "Backend", 5, 6, "var(--color-teal-500)"), bar("qa", "QA pass", 12, 4, "var(--color-amber-500)"), ];}
/** * i18n presets - each language ships a date-fns `locale` (localizes the axis * headers, the nav title, and bar date labels; it also drives the default * week start, so a German timeline starts Monday) plus an `i18n` override map * for the static strings the locale can't reach (Today, the scale names, the * schedule hint). Arabic also flips the chart to right-to-left. English is the * built-in default, so it leaves both undefined. */type DemoLocale = { id: string; /** Native language name, shown in the picker. */ label: string; locale: Locale | undefined; dir: "ltr" | "rtl"; i18n: GanttI18nOverrides | undefined;};
const LOCALES: DemoLocale[] = [ { id: "en", label: "English", locale: undefined, dir: "ltr", i18n: undefined }, { id: "de", label: "Deutsch", locale: de, dir: "ltr", i18n: { labels: { today: "Heute", scheduleHint: "Zum Planen klicken", reorder: "Neu anordnen", scales: { day: "Tag", week: "Woche", month: "Monat", quarter: "Quartal", year: "Jahr" }, }, }, }, { id: "fr", label: "Français", locale: fr, dir: "ltr", i18n: { labels: { today: "Aujourd'hui", scheduleHint: "Cliquer pour planifier", reorder: "Réorganiser", scales: { day: "Jour", week: "Semaine", month: "Mois", quarter: "Trimestre", year: "Année", }, }, }, }, { id: "es", label: "Español", locale: es, dir: "ltr", i18n: { labels: { today: "Hoy", scheduleHint: "Clic para programar", reorder: "Reordenar", scales: { day: "Día", week: "Semana", month: "Mes", quarter: "Trimestre", year: "Año" }, }, }, }, { id: "ja", label: "日本語", locale: ja, dir: "ltr", i18n: { labels: { today: "今日", scheduleHint: "クリックして予定を追加", reorder: "並べ替え", scales: { day: "日", week: "週", month: "月", quarter: "四半期", year: "年" }, }, }, }, { id: "ar", label: "العربية", locale: ar, dir: "rtl", i18n: { labels: { today: "اليوم", scheduleHint: "انقر لإضافة جدول", reorder: "إعادة ترتيب", scales: { day: "يوم", week: "أسبوع", month: "شهر", quarter: "ربع سنوي", year: "سنة" }, }, }, },];
/** Display time zones - all timeline math and rendering happen in the chosen * zone, so switching it re-anchors every bar to that zone's calendar days. */const TIME_ZONES: Array<{ id: string; label: string; value?: string }> = [ { id: "local", label: "Browser" }, { id: "ny", label: "New York", value: "America/New_York" }, { id: "london", label: "London", value: "Europe/London" }, { id: "tokyo", label: "Tokyo", value: "Asia/Tokyo" }, { id: "kolkata", label: "Kolkata", value: "Asia/Kolkata" },];
type DemoSettings = { rowCheckboxes: boolean; summaryBars: boolean; zoomControl: boolean; offscreenIndicators: boolean; infiniteScroll: boolean; nowIndicator: boolean; offDays: boolean; dragCreate: boolean; displayScheduleHint: boolean; barLabel: "inside" | "outside" | "auto"; timelineLines: "vertical" | "both" | "none"; interactions: GanttInteractions; localeId: string; timeZoneId: string;};
/** Every toggle's default; the Reset button returns the demo here. */const SETTINGS_DEFAULTS: DemoSettings = { rowCheckboxes: true, summaryBars: true, zoomControl: true, offscreenIndicators: true, infiniteScroll: true, nowIndicator: true, offDays: false, dragCreate: true, displayScheduleHint: true, barLabel: "inside", timelineLines: "vertical", interactions: { drag: true, resize: true, selectSlot: true }, localeId: "en", timeZoneId: "local",};
/** One labeled switch row inside a settings tab. */function SettingSwitch(props: { id: string; label: string; checked: boolean; onChange: (value: boolean) => void;}) { return ( <div class="flex items-center justify-between gap-4 py-1"> <Label for={props.id} class="font-normal"> {props.label} </Label> <Switch id={props.id} checked={props.checked} onChange={props.onChange} /> </div> );}
/** One labeled radio row inside a settings tab. */function SettingRadio(props: { id: string; value: string; label: string }) { return ( <div class="flex items-center gap-2 py-0.5"> <RadioGroupItem value={props.value} id={props.id} /> <Label for={props.id} class="font-normal"> {props.label} </Label> </div> );}
type SelectOption = { value: string; label: string };
/** One labeled select row - the language and time-zone pickers. */function SettingSelect(props: { id: string; label: string; value: string; options: SelectOption[]; onValueChange: (value: string) => void;}) { const selected = () => props.options.find((option) => option.value === props.value);
return ( <div class="flex items-center justify-between gap-4 py-1"> <Label for={props.id} class="font-normal"> {props.label} </Label> <Select<SelectOption> options={props.options} optionValue="value" optionTextValue="label" value={selected()} onChange={(option) => option && props.onValueChange(option.value)} itemComponent={(itemProps) => ( <SelectItem item={itemProps.item}>{itemProps.item.rawValue.label}</SelectItem> )} > <SelectTrigger id={props.id} size="sm" class="w-36" aria-label={props.label}> {/* Kobalte's Value renders the raw value by default; the selected option's label reads better here. */} <SelectValue<SelectOption>>{(state) => state.selectedOption().label}</SelectValue> </SelectTrigger> <SelectContent /> </Select> </div> );}
function SettingsMenu(props: { settings: DemoSettings; onChange: <K extends keyof DemoSettings>(key: K, value: DemoSettings[K]) => void; onInteractionChange: (key: keyof GanttInteractions, value: boolean) => void; onReset: () => void;}): JSX.Element { return ( <Popover placement="bottom-end"> <PopoverTrigger as={Button} variant="outline" size="sm"> <Settings class="size-4" aria-hidden="true" /> Settings </PopoverTrigger> <PopoverContent class="w-80 p-0"> {/* Tabs keep every group one screen tall - no menu scrolling */} <Tabs defaultValue="display"> <div class="border-b p-2"> <TabsList class="grid w-full grid-cols-4"> <TabsTrigger value="display">Display</TabsTrigger> <TabsTrigger value="behavior">Behavior</TabsTrigger> <TabsTrigger value="style">Style</TabsTrigger> <TabsTrigger value="region">Region</TabsTrigger> </TabsList> </div> <TabsContent value="display" class="space-y-0.5 p-3"> <SettingSwitch id="gantt-demo-row-checkboxes" label="Row checkboxes" checked={props.settings.rowCheckboxes} onChange={(value) => props.onChange("rowCheckboxes", value)} /> <SettingSwitch id="gantt-demo-summary-bars" label="Summary bars" checked={props.settings.summaryBars} onChange={(value) => props.onChange("summaryBars", value)} /> <SettingSwitch id="gantt-demo-zoom-control" label="Zoom control" checked={props.settings.zoomControl} onChange={(value) => props.onChange("zoomControl", value)} /> <SettingSwitch id="gantt-demo-offscreen-chips" label="Off-screen chips" checked={props.settings.offscreenIndicators} onChange={(value) => props.onChange("offscreenIndicators", value)} /> <SettingSwitch id="gantt-demo-infinite-scroll" label="Infinite scroll" checked={props.settings.infiniteScroll} onChange={(value) => props.onChange("infiniteScroll", value)} /> <SettingSwitch id="gantt-demo-now-indicator" label="Now indicator" checked={props.settings.nowIndicator} onChange={(value) => props.onChange("nowIndicator", value)} /> <SettingSwitch id="gantt-demo-off-days" label="Mark off days" checked={props.settings.offDays} onChange={(value) => props.onChange("offDays", value)} /> </TabsContent> <TabsContent value="behavior" class="space-y-0.5 p-3"> <SettingSwitch id="gantt-demo-drag" label="Drag to move" checked={props.settings.interactions.drag} onChange={(value) => props.onInteractionChange("drag", value)} /> <SettingSwitch id="gantt-demo-resize" label="Resize" checked={props.settings.interactions.resize} onChange={(value) => props.onInteractionChange("resize", value)} /> <SettingSwitch id="gantt-demo-select-slot" label="Select slot" checked={props.settings.interactions.selectSlot} onChange={(value) => props.onInteractionChange("selectSlot", value)} /> <SettingSwitch id="gantt-demo-drag-create" label="Drag to create" checked={props.settings.dragCreate} onChange={(value) => props.onChange("dragCreate", value)} /> <SettingSwitch id="gantt-demo-schedule-hint" label="Schedule hint" checked={props.settings.displayScheduleHint} onChange={(value) => props.onChange("displayScheduleHint", value)} /> </TabsContent> <TabsContent value="style" class="space-y-4 p-3"> <div class="space-y-1.5"> <div class="font-medium text-muted-foreground text-xs">Bar label</div> <RadioGroup value={props.settings.barLabel} onChange={(value) => props.onChange("barLabel", value as DemoSettings["barLabel"])} > <SettingRadio id="gantt-demo-label-inside" value="inside" label="Inside" /> <SettingRadio id="gantt-demo-label-outside" value="outside" label="Outside" /> <SettingRadio id="gantt-demo-label-auto" value="auto" label="Auto" /> </RadioGroup> </div> <div class="space-y-1.5"> <div class="font-medium text-muted-foreground text-xs">Grid lines</div> <RadioGroup value={props.settings.timelineLines} onChange={(value) => props.onChange("timelineLines", value as DemoSettings["timelineLines"]) } > <SettingRadio id="gantt-demo-lines-vertical" value="vertical" label="Vertical" /> <SettingRadio id="gantt-demo-lines-both" value="both" label="Both" /> <SettingRadio id="gantt-demo-lines-none" value="none" label="None" /> </RadioGroup> </div> </TabsContent> <TabsContent value="region" class="space-y-2 p-3"> <SettingSelect id="gantt-demo-language" label="Language" value={props.settings.localeId} options={LOCALES.map((entry) => ({ value: entry.id, label: entry.label }))} onValueChange={(value) => props.onChange("localeId", value)} /> <SettingSelect id="gantt-demo-timezone" label="Time zone" value={props.settings.timeZoneId} options={TIME_ZONES.map((entry) => ({ value: entry.id, label: entry.label }))} onValueChange={(value) => props.onChange("timeZoneId", value)} /> <p class="text-muted-foreground text-xs leading-relaxed"> Language switches the date-fns locale, the scale names, and the week start. Time zone re-anchors every bar. Arabic also flips the chart to right-to-left. </p> </TabsContent> </Tabs> <div class="border-t p-2"> <Button variant="outline" size="sm" class="w-full" onClick={props.onReset}> <RotateCcw class="size-3.5" aria-hidden="true" /> Reset to defaults </Button> </div> </PopoverContent> </Popover> );}
export default function GanttDemo() { const bars = buildBars(new Date()); let api: GanttApi | undefined; const [settings, setSettings] = createStore<DemoSettings>({ ...SETTINGS_DEFAULTS, interactions: { ...SETTINGS_DEFAULTS.interactions }, });
const activeLocale = () => LOCALES.find((entry) => entry.id === settings.localeId) ?? LOCALES[0]; const activeTimeZone = () => TIME_ZONES.find((entry) => entry.id === settings.timeZoneId) ?? TIME_ZONES[0];
const resetSettings = () => setSettings({ ...SETTINGS_DEFAULTS, interactions: { ...SETTINGS_DEFAULTS.interactions } });
// Unscheduled rows accept ONE schedule: the hint tile (or a drag-create // range) proposes a slot, and the handler turns it into a real bar. const canSelectSlot = (slot: GanttSlotDraft) => !!slot.resourceId && !(api?.getEvents() ?? []).some((event) => event.resourceId === slot.resourceId);
const handleSelectSlot = (slot: GanttSlotDraft) => { if (!api || !slot.resourceId) return; api.addEvent({ id: `scheduled-${slot.resourceId}`, title: RESOURCE_TITLES.get(slot.resourceId) ?? "New schedule", start: slot.start, end: slot.end, allDay: true, color: "var(--color-indigo-500)", resourceId: slot.resourceId, }); };
return ( <div class="w-full p-4" dir={activeLocale().dir}> <Card class="w-full py-0"> <CardContent class="p-0"> <Gantt defaultEvents={bars} resources={RESOURCES} defaultScale="month" apiRef={(instance) => { api = instance; }} locale={activeLocale().locale} i18n={activeLocale().i18n} timeZone={activeTimeZone().value} treePanel={{ width: 200 }} rowCheckboxes={settings.rowCheckboxes} summaryBars={settings.summaryBars} zoomControl={settings.zoomControl} offscreenIndicators={settings.offscreenIndicators} infiniteScroll={settings.infiniteScroll} nowIndicator={settings.nowIndicator} offDays={settings.offDays} dragCreate={settings.dragCreate} displayScheduleHint={settings.displayScheduleHint} barLabel={settings.barLabel} timelineLines={settings.timelineLines} interactions={settings.interactions} onInteractionsChange={(next) => setSettings("interactions", next)} canSelectSlot={canSelectSlot} onSelectSlot={handleSelectSlot} class="h-[520px] w-full" > {/* one bordered header row, same look as the plain GanttNav: the row owns the border and end padding so the toolbar never sits glued to the edge */} <div class="flex flex-wrap items-center gap-2 border-b pe-3"> <GanttNav class="min-w-0 flex-1 border-b-0" /> <GanttToolbar> <SettingsMenu settings={settings} onChange={(key, value) => setSettings(key, value)} onInteractionChange={(key, value) => setSettings("interactions", key, value)} onReset={resetSettings} /> </GanttToolbar> </div> <GanttView /> </Gantt> </CardContent> </Card> </div> );}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
Usage
import { Gantt, type GanttEvent, GanttNav, type GanttResource, GanttToolbar, GanttView,} from "~/components/blocks/gantt";const resources: GanttResource[] = [ { id: "design", title: "Design", children: [ { id: "wireframes", title: "Wireframes" }, { id: "visual-design", title: "Visual design" }, ], },];
const events: GanttEvent[] = [ { id: "1", title: "Wireframes", start: new Date("2026-07-06"), end: new Date("2026-07-10"), allDay: true, resourceId: "wireframes", progress: 60, },];
return ( <Gantt defaultEvents={events} resources={resources} defaultScale="month" class="h-[480px]"> <GanttNav /> <GanttView /> </Gantt>);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
import { addDays, startOfDay, startOfWeek } from "date-fns";import { ChevronLeft, ChevronRight, Plus } from "lucide-solid";import { createSignal } from "solid-js";import { Gantt, type GanttApi, type GanttEvent, GanttNav, type GanttResource, GanttToolbar, GanttView,} from "@/registry/kobalte/blocks/gantt";import { Button } from "~/components/ui/button";import { Card, CardContent } from "~/components/ui/card";import { ContextMenuItem } from "~/components/ui/context-menu";
/** * Yearly roadmap: each workstream is a swimlane group and its multi-month * initiatives are the bars. The Backlog group ships with empty rows - the * toolbar button schedules the next one onto the timeline. */const RESOURCES: GanttResource[] = [ { id: "platform", title: "Platform", children: [ { id: "auth-revamp", title: "Auth Revamp" }, { id: "api-v2", title: "API v2" }, ], }, { id: "growth", title: "Growth", children: [ { id: "onboarding", title: "Onboarding Flow" }, { id: "referrals", title: "Referral Program" }, ], }, { id: "design-system", title: "Design System", children: [ { id: "tokens", title: "Design Tokens" }, { id: "components", title: "Component Library" }, ], }, { id: "backlog", title: "Backlog", children: [ { id: "search", title: "Search Revamp" }, { id: "billing", title: "Billing v2" }, { id: "mobile", title: "Mobile App" }, ], },];
/** Backlog initiatives, scheduled one per click in this order. */const BACKLOG: Array<{ id: string; title: string; color: string }> = [ { id: "search", title: "Search Revamp", color: "var(--color-rose-500)" }, { id: "billing", title: "Billing v2", color: "var(--color-amber-500)" }, { id: "mobile", title: "Mobile App", color: "var(--color-cyan-500)" },];
/** Roadmap fixture - initiatives span months so the quarter axis has something * to show, and offsets straddle today so both past and future are visible. */function buildBars(anchor: Date): GanttEvent[] { const week = startOfWeek(startOfDay(anchor), { weekStartsOn: 0 }); const day = (dayOffset: number) => addDays(week, dayOffset); const bar = ( resourceId: string, title: string, startOffset: number, days: number, color: string, progress?: number, ): GanttEvent => ({ id: `bar-${resourceId}`, title, start: day(startOffset), end: day(startOffset + days), allDay: true, color, resourceId, progress, });
return [ bar("auth-revamp", "Auth Revamp", -30, 60, "var(--color-blue-500)", 100), bar("api-v2", "API v2", 20, 90, "var(--color-sky-500)", 30), bar("onboarding", "Onboarding Flow", -20, 60, "var(--color-emerald-500)", 80), bar("referrals", "Referral Program", 50, 90, "var(--color-teal-500)", 0), bar("tokens", "Design Tokens", -60, 70, "var(--color-violet-500)", 100), bar("components", "Component Library", 0, 120, "var(--color-purple-500)", 45), ];}
export default function GanttRoadmap() { const bars = buildBars(new Date()); let api: GanttApi | undefined; // How many backlog initiatives have been scheduled so far. const [scheduled, setScheduled] = createSignal(0);
// Schedule the next unscheduled backlog initiative onto its (empty) row. // The "next" one is derived from the live events, not a captured counter, // so it stays correct even if the button is clicked in quick succession. const addInitiative = () => { if (!api) return; const scheduledIds = new Set(api.getEvents().map((event) => event.id)); const index = BACKLOG.findIndex((item) => !scheduledIds.has(`bar-${item.id}`)); if (index === -1) return; const item = BACKLOG[index]; const week = startOfWeek(startOfDay(new Date()), { weekStartsOn: 0 }); // Land the scheduled bars in the near-future part of the current quarter so // each one is visible the moment it drops onto its row. const start = addDays(week, 12 + index * 20); api.addEvent({ id: `bar-${item.id}`, title: item.title, start, end: addDays(start, 24), allDay: true, color: item.color, resourceId: item.id, }); setScheduled((count) => count + 1); };
// Slide one initiative a quarter in either direction. Timing changes made // through the api route through `onEventUpdate` exactly like a drag does, // with `source: "api"` on the proposal. const shiftInitiative = (eventId: string, days: number) => { const event = api?.getEvent(eventId); if (!api || !event) return; api.updateEvent(eventId, { start: addDays(event.start, days), end: addDays(event.end, days), }); };
return ( <div class="w-full p-4"> <Card class="w-full py-0"> <CardContent class="p-0"> <Gantt defaultEvents={bars} resources={RESOURCES} defaultScale="quarter" apiRef={(instance) => { api = instance; }} treePanel={{ width: 200 }} // A workstream is done when its initiatives are, so the group // rollups count finished initiatives instead of the default // duration-weighted mean progress. getSummaryProgress={(ctx) => { const scored = ctx.events.filter((event) => event.progress !== undefined); if (scored.length === 0) return null; const done = scored.filter((event) => (event.progress ?? 0) >= 100).length; return Math.round((done / scored.length) * 100); }} // Right-click an initiative to reschedule it. The gantt owns the // context menu; the items are yours. renderEventMenu={(ctx) => ( <> <ContextMenuItem onSelect={() => shiftInitiative(ctx.occurrence.eventId, -90)}> <ChevronLeft aria-hidden="true" /> Pull in a quarter </ContextMenuItem> <ContextMenuItem onSelect={() => shiftInitiative(ctx.occurrence.eventId, 90)}> <ChevronRight aria-hidden="true" /> Push out a quarter </ContextMenuItem> </> )} class="h-[480px] w-full" > <div class="flex flex-wrap items-center gap-2 border-b pe-3"> <GanttNav class="min-w-0 flex-1 border-b-0" /> <GanttToolbar> <Button variant="outline" size="sm" onClick={addInitiative} disabled={scheduled() >= BACKLOG.length} > <Plus class="size-4" aria-hidden="true" /> Add to roadmap </Button> </GanttToolbar> </div> {/* Parent workstream rows carry no bars - summaryBars rolls up the child initiatives into one envelope on the group row. */} <GanttView /> </Gantt> </CardContent> </Card> </div> );}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
import { addDays, startOfDay, startOfWeek } from "date-fns";import { Plus } from "lucide-solid";import { Gantt, type GanttApi, type GanttEvent, GanttNav, type GanttResource, GanttToolbar, GanttView,} from "@/registry/kobalte/blocks/gantt";import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";import { Button } from "~/components/ui/button";import { Card, CardContent } from "~/components/ui/card";
/** * People-centric capacity board: rows are teammates grouped by squad, and * each teammate's bars are the week's assignments. The toolbar drops a fresh * assignment onto the next teammate, so a person can hold several at once. */const RESOURCES: GanttResource[] = [ { id: "product-squad", title: "Product Squad", children: [ { id: "ada", title: "Ada Lovelace" }, { id: "alan", title: "Alan Turing" }, ], }, { id: "design-squad", title: "Design Squad", children: [ { id: "grace", title: "Grace Hopper" }, { id: "linus", title: "Linus Torvalds" }, ], },];
/** Avatar photo + initials keyed by person id - GanttResource carries no * custom fields, so per-row presentation data lives in a lookup of your own. * The initials show while the image loads or if it fails. */const RESOURCE_META: Record<string, { initials: string; avatar: string }> = { ada: { initials: "AL", avatar: "https://randomuser.me/api/portraits/women/44.jpg" }, alan: { initials: "AT", avatar: "https://randomuser.me/api/portraits/men/32.jpg" }, grace: { initials: "GH", avatar: "https://randomuser.me/api/portraits/women/68.jpg" }, linus: { initials: "LT", avatar: "https://randomuser.me/api/portraits/men/54.jpg" },};
/** Teammates the new assignments cycle through, and a small pool to name and * color them from. */const PEOPLE = ["ada", "alan", "grace", "linus"];const TASK_POOL = [ { title: "Bug triage", color: "var(--color-amber-500)" }, { title: "Code review", color: "var(--color-rose-500)" }, { title: "Spec draft", color: "var(--color-teal-500)" }, { title: "Pairing", color: "var(--color-indigo-500)" },];
/** This-week assignments per teammate - day-precise bars for a capacity read * (progress omitted; occupancy, not percent-done, is the point here). */function buildBars(anchor: Date): GanttEvent[] { const week = startOfWeek(startOfDay(anchor), { weekStartsOn: 0 }); const day = (dayOffset: number) => addDays(week, dayOffset); const bar = ( resourceId: string, title: string, startOffset: number, days: number, color: string, ): GanttEvent => ({ id: `bar-${resourceId}`, title, start: day(startOffset), end: day(startOffset + days), allDay: true, color, resourceId, });
return [ bar("ada", "Checkout API", -1, 3, "var(--color-blue-500)"), bar("alan", "Search Indexing", 0, 3, "var(--color-sky-500)"), bar("grace", "Dashboard Redesign", 1, 3, "var(--color-violet-500)"), bar("linus", "Infra Migration", -2, 3, "var(--color-purple-500)"), ];}
/** Company holiday: the Thursday of the rendered week. */function holiday() { return addDays(startOfWeek(startOfDay(new Date()), { weekStartsOn: 0 }), 4);}
export default function GanttCapacity() { const bars = buildBars(new Date()); let api: GanttApi | undefined; // A plain counter (not a signal) numbers the adds, so it advances // synchronously and stays correct when the button is clicked several // times in a row. Nothing in the UI reads it, so nothing has to react. let added = 0;
// Add a 2-day assignment to the next teammate in rotation. Successive adds // to the same person overlap and stack into extra lanes on that row. const addAssignment = () => { if (!api) return; const n = added++; const person = PEOPLE[n % PEOPLE.length]; const task = TASK_POOL[n % TASK_POOL.length]; const week = startOfWeek(startOfDay(new Date()), { weekStartsOn: 0 }); const start = addDays(week, (n % 5) + 1); api.addEvent({ id: `bar-extra-${n}`, title: task.title, start, end: addDays(start, 2), allDay: true, color: task.color, resourceId: person, }); };
return ( <div class="w-full p-4"> <Card class="w-full py-0"> <CardContent class="p-0"> <Gantt defaultEvents={bars} resources={RESOURCES} defaultScale="week" apiRef={(instance) => { api = instance; }} // Weekends shaded so booked working-day capacity is obvious, plus // one company holiday. A custom `class` replaces the default // marker surface outright. offDays={{ weekendDays: [0, 6], dates: [holiday()], class: "bg-muted/50", }} // Rows grow as assignments stack into extra lanes; centering keeps // each teammate's avatar against the middle of their own row. rowAlign="center" treePanel={{ width: 220 }} // People rows get an avatar label; group (squad) rows return // undefined to keep the default plain-title label. renderResourceLabel={(ctx) => { if (ctx.isGroup) return undefined; const person = RESOURCE_META[ctx.resource.id]; return ( <span class="flex min-w-0 items-center gap-2"> <Avatar class="size-5"> <AvatarImage src={person?.avatar} alt={ctx.resource.title} /> <AvatarFallback class="text-[10px]"> {person?.initials ?? ctx.resource.title.charAt(0)} </AvatarFallback> </Avatar> <span class="truncate">{ctx.resource.title}</span> </span> ); }} class="h-[440px] w-full" > <div class="flex flex-wrap items-center gap-2 border-b pe-3"> <GanttNav class="min-w-0 flex-1 border-b-0" /> <GanttToolbar> <Button variant="outline" size="sm" onClick={addAssignment}> <Plus class="size-4" aria-hidden="true" /> Add assignment </Button> </GanttToolbar> </div> <GanttView /> </Gantt> </CardContent> </Card> </div> );}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
import { addDays, startOfDay, startOfWeek } from "date-fns";import { Plus, SlidersHorizontal } from "lucide-solid";import { createSignal, For } from "solid-js";import { Gantt, type GanttApi, type GanttColumn, type GanttEvent, GanttNav, type GanttResource, GanttToolbar, GanttView,} from "@/registry/kobalte/blocks/gantt";import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";import { Badge } from "~/components/ui/badge";import { Button } from "~/components/ui/button";import { Card, CardContent } from "~/components/ui/card";import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuTrigger,} from "~/components/ui/dropdown-menu";
type TaskMeta = { owner: string; status: string };
/** Owner headshots keyed by name. Missing entries (e.g. "Unassigned") fall * back to initials, and the initials also show while the photo loads. */const OWNER_AVATARS: Record<string, string> = { "Ada Lovelace": "https://randomuser.me/api/portraits/women/44.jpg", "Grace Hopper": "https://randomuser.me/api/portraits/women/68.jpg", "Alan Turing": "https://randomuser.me/api/portraits/men/32.jpg", "Linus Torvalds": "https://randomuser.me/api/portraits/men/54.jpg", "Katherine Johnson": "https://randomuser.me/api/portraits/women/90.jpg", "Margaret Hamilton": "https://randomuser.me/api/portraits/women/12.jpg",};
/** First + last initial from an owner name, used as the avatar fallback - * "Ada Lovelace" reads "AL", "Unassigned" reads "UN". */function ownerInitials(name: string) { const parts = name.trim().split(/\s+/).filter(Boolean); if (parts.length === 0) return "?"; if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();}
/** Status label -> badge tint (green done, amber in progress, neutral not * started). Zaidan's Badge has no coloured "light" variants, so the tint is * raw Tailwind on top of the secondary variant. */const STATUS_CLASS: Record<string, string> = { Done: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400", "In progress": "bg-amber-500/15 text-amber-700 dark:text-amber-400", "Not started": "",};
/** * Status report: the left tree panel doubles as a task table with Owner and * Status columns beside each phase, every bar carries a progress fill, and the * timeline is drag-locked. New tasks are appended as rows from the toolbar. */const INITIAL_RESOURCES: GanttResource[] = [ { id: "planning", title: "Planning", children: [ { id: "requirements", title: "Requirements" }, { id: "design-phase", title: "Design" }, ], }, { id: "build", title: "Build", children: [ { id: "frontend", title: "Frontend" }, { id: "backend", title: "Backend" }, ], }, { id: "launch", title: "Launch", children: [ { id: "qa", title: "QA & Testing" }, { id: "rollout", title: "Rollout" }, ], },];
/** Owner + status per task, keyed by resource.id - GanttResource has no room * for custom fields, so the extra column data lives in a lookup of your own. */const INITIAL_META: Record<string, TaskMeta> = { requirements: { owner: "Ada Lovelace", status: "Done" }, "design-phase": { owner: "Grace Hopper", status: "Done" }, frontend: { owner: "Alan Turing", status: "In progress" }, backend: { owner: "Linus Torvalds", status: "In progress" }, qa: { owner: "Katherine Johnson", status: "Not started" }, rollout: { owner: "Margaret Hamilton", status: "Not started" },};
/** Status-report fixture - progress descends from finished planning to * not-started launch, and the phases straddle today so the now-line falls * mid-plan. Kept inside a month so every phase reads at a glance. */function buildBars(anchor: Date): GanttEvent[] { const week = startOfWeek(startOfDay(anchor), { weekStartsOn: 0 }); const day = (dayOffset: number) => addDays(week, dayOffset); const bar = ( resourceId: string, title: string, startOffset: number, days: number, color: string, progress?: number, ): GanttEvent => ({ id: `bar-${resourceId}`, title, start: day(startOffset), end: day(startOffset + days), allDay: true, color, resourceId, progress, });
return [ bar("requirements", "Requirements", -10, 8, "var(--color-blue-500)", 100), bar("design-phase", "Design", -8, 8, "var(--color-sky-500)", 100), bar("frontend", "Frontend", -2, 10, "var(--color-violet-500)", 60), bar("backend", "Backend", 0, 10, "var(--color-purple-500)", 45), bar("qa", "QA & Testing", 8, 8, "var(--color-amber-500)", 0), bar("rollout", "Rollout", 14, 5, "var(--color-emerald-500)", 0), ];}
/** How many tasks the tree-foot "Add task" hint may append before it hides. */const CREATE_TASK_LIMIT = 4;
export default function GanttStatusReport() { const bars = buildBars(new Date()); let api: GanttApi | undefined; const [resources, setResources] = createSignal<GanttResource[]>(INITIAL_RESOURCES); const [meta, setMeta] = createSignal<Record<string, TaskMeta>>(INITIAL_META); // Numbers each appended task so ids stay unique. A signal, because // `canCreateTask` reads it to retire the create hint at the limit. const [added, setAdded] = createSignal(0); const [hiddenColumns, setHiddenColumns] = createSignal<string[]>([]);
// Extra tree-panel columns after the pinned name column. The definitions // never change - each `render` reads `meta()` where it is called, so // appended rows pick up their Owner and Status with no new array; group // rows return null. const columns: GanttColumn[] = [ { id: "owner", title: "Owner", width: 130, align: "start", render: (ctx) => { if (ctx.isGroup) return null; const owner = meta()[ctx.resource.id]?.owner; if (!owner) return null; return ( <span class="flex min-w-0 items-center gap-2"> <Avatar class="size-5 shrink-0"> <AvatarImage src={OWNER_AVATARS[owner]} alt={owner} /> <AvatarFallback class="text-[10px]">{ownerInitials(owner)}</AvatarFallback> </Avatar> <span class="truncate">{owner}</span> </span> ); }, }, { id: "status", title: "Status", width: 100, align: "start", render: (ctx) => { if (ctx.isGroup) return null; const status = meta()[ctx.resource.id]?.status; if (!status) return null; return ( <Badge variant="secondary" class={STATUS_CLASS[status]}> {status} </Badge> ); }, }, ];
/** The columns the menu currently leaves visible. */ const visibleColumns = () => columns.filter((column) => !hiddenColumns().includes(column.id));
const toggleColumn = (id: string, visible: boolean) => setHiddenColumns((prev) => (visible ? prev.filter((entry) => entry !== id) : [...prev, id]));
// Append a new task, register its Owner/Status, and drop a not-started bar // on its row. `parent` is the phase it lands under, or null for a row of // its own at the top level. const addTask = (parent: string | null) => { if (!api) return; const n = added() + 1; setAdded(n); const id = `task-${n}`; const node: GanttResource = { id, title: `New task ${n}` }; setResources((prev) => parent === null ? [...prev, node] : prev.map((group) => group.id === parent ? { ...group, children: [...(group.children ?? []), node] } : group, ), ); setMeta((prev) => ({ ...prev, [id]: { owner: "Unassigned", status: "Not started" } })); const week = startOfWeek(startOfDay(new Date()), { weekStartsOn: 0 }); const start = addDays(week, (n % 6) - 2); api.addEvent({ id: `bar-${id}`, title: `New task ${n}`, start, end: addDays(start, 5), allDay: true, color: "var(--color-slate-400)", resourceId: id, progress: 0, }); };
return ( <div class="w-full p-4"> <Card class="w-full py-0"> <CardContent class="p-0"> <Gantt defaultEvents={bars} resources={resources()} defaultScale="month" apiRef={(instance) => { api = instance; }} // Read-only timeline: drag, resize and slot-select are off so the // plan can't be shifted by dragging; rows are added via the toolbar. defaultInteractions={{ drag: false, resize: false, selectSlot: false }} columns={visibleColumns()} // Pinned at the end of the tree header - the intended home for a // columns dropdown. columnsMenu={ <DropdownMenu placement="bottom-end"> <DropdownMenuTrigger as={Button} variant="ghost" size="icon-sm" aria-label="Toggle columns" > <SlidersHorizontal aria-hidden="true" /> </DropdownMenuTrigger> <DropdownMenuContent class="w-40"> {/* Kobalte's label is a group label: it throws outside a DropdownMenuGroup. */} <DropdownMenuGroup> <DropdownMenuLabel>Columns</DropdownMenuLabel> <For each={columns}> {(column) => ( <DropdownMenuCheckboxItem checked={!hiddenColumns().includes(column.id)} onChange={(checked) => toggleColumn(column.id, checked)} > {column.title} </DropdownMenuCheckboxItem> )} </For> </DropdownMenuGroup> </DropdownMenuContent> </DropdownMenu> } // The tree foot offers root-level creation only, so the hint files // its task as its own top-level row; `canCreateTask` retires the // affordance once the report has enough of them. displayCreateTaskHint canCreateTask={() => added() < CREATE_TASK_LIMIT} onCreateTask={() => addTask(null)} // Wider tree with a tighter name column so the Owner avatar, // owner name and Status all fit alongside the task names. treePanel={{ width: 400, nameColumnWidth: 150 }} class="h-[500px] w-full" > <div class="flex flex-wrap items-center gap-2 border-b pe-3"> <GanttNav class="min-w-0 flex-1 border-b-0" /> <GanttToolbar> <Button variant="outline" size="sm" onClick={() => addTask("launch")}> <Plus class="size-4" aria-hidden="true" /> Add task </Button> </GanttToolbar> </div> <GanttView /> </Gantt> </CardContent> </Card> </div> );}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.
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.
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.
GanttNavPrev
Icon button that steps the anchor date one period back at the current scale.
GanttNavNext
Icon button that steps the anchor date one period forward at the current scale.
GanttTitle
The current period title, formatted by i18n.functions.formatTitle and announced politely on change.
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.
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.
GanttToolbar
Free slot for consumer toolbar buttons; a pure layout shell that also picks up classNames.toolbar from the view configuration.
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.
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.
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.
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.
GanttOccurrence
One expanded instance of an event within the visible range (recurring events expand to many).
GanttSegment
The slice of an occurrence rendered inside one timeline range, with lane packing metadata. Passed to GanttBar and every render override.
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.
GanttWeekday is "MO" \| "TU" \| "WE" \| "TH" \| "FR" \| "SA" \| "SU".
GanttProposedUpdate
The proposal handed to onEventUpdate and canDropEvent for every timing change.
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.
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.
GanttSelection
The committed selection state.
GanttResourceReorder
Proposal emitted when a timeline resource row is drag-reordered. Payload of onResourceReorder, canReorderResource, and onResourceReorderReject.
GanttRangeInfo
Payload of onRangeChange; fires once on mount and whenever the rendered range changes. Fetch remote data for range.
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.
GanttDragState
The in-flight gesture stored in state.drag.
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.
GanttColumnContext
Row context handed to tree-panel column renderers, renderResourceLabel, renderResourceMenu, and the resource click callbacks.
GanttDragIndicatorProps
Live gesture snapshot handed to renderDragPreview and renderResizeIndicator; the content re-renders per snap step while the gantt writes the wrapper position imperatively.
GanttScheduleHintProps
Slot handed to a custom renderScheduleHint renderer.
GanttSummaryProps
Parent rollup handed to a custom renderSummary renderer.
GanttApi
The imperative surface, available as instance.api from useGantt/useGanttState or through the root apiRef callback.
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.
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.
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.
Config
State options
State and configuration options accepted by useGanttState and, as flat props, by <Gantt>. Every controlled prop has an uncontrolled default* twin.
Callbacks and validators
All callbacks live beside the state options on useGanttState and <Gantt>.
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.
GanttInteractions
Global interaction switches; per-event readOnly, draggable, and resizable override them.
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.
GanttTreePanelConfig
Left tree-panel sizing and splitter behavior.
GanttColumn
One extra tree-panel column after the built-in name column.
GanttMetrics
Layout metrics (rem unless noted); every knob falls back to its default.
GanttActivationConfig
Pointer-activation thresholds; unset keys keep the dnd-kit parity defaults.
GanttClassNames
Class overrides for the composed parts. This is the shadcn slot-map convention, not a React leftover: the individual components take class.
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:
formats are date-fns format strings, applied with the gantt locale:
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: