Gantt chart
Work as bars on a time axis: grouped rows, a sticky label column, milestones, a today line and a weeks or months zoom.
Every date is read through its UTC parts, so a bar sits on the same day for a reader in Los Angeles and one in Berlin — a chart that read local parts would move every bar back a day for half the planet. The offsets it placed each bar at are written onto the bar itself, data-day-start and data-day-span in whole days from the start of the range, so what the picture says is readable from the DOM and testable without measuring pixels. Left off, range is the outermost pair of dates in the data; given one, a bar that runs past either end is clipped to it and marked data-clipped, and a task that falls entirely outside is left out. Each bar is a button whose accessible name carries what its position encodes — the label, the two dates and the share done — because a screen reader cannot read a picture, which is also why there is no separate sr-only row of dates beside it: hearing them on the bar itself takes that row's place. The sticky label column is what a reader walks. A milestone is a moment rather than a stretch and is drawn as a marker on its start day. Colour comes from the group's own place in the eight-hue palette unless a task names a tone, and every bar carries a full-strength ring under a quarter-strength track, so a bar with no progress in it still clears 3:1 against the card.
Install
npx shadcn@latest add @vibra/gantt-chartNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { ChartCard } from "@/components/ui/chart-card"
import { GanttChart, type GanttGroup } from "@/components/ui/gantt-chart"
import { SegmentedControl } from "@/components/ui/segmented-control"
const day = (iso: string) => new Date(`${iso}T00:00:00.000Z`)
// Pinned dates rather than a clock read, so the docs draw the same picture on
// the server and in the browser.
const TODAY = day("2026-09-04")
const GROUPS: GanttGroup[] = [
{
id: "atlas",
label: "Atlas migration",
tasks: [
{ id: "a1", label: "Shape the schema", start: day("2026-08-10"), end: day("2026-08-28"), progress: 100 },
{ id: "a2", label: "Backfill the rows", start: day("2026-08-24"), end: day("2026-09-18"), progress: 45 },
{ id: "a3", label: "Dual-write window", start: day("2026-09-14"), end: day("2026-10-02"), progress: 0 },
{ id: "a4", label: "Cutover", start: day("2026-10-05"), end: day("2026-10-05"), milestone: true },
],
},
{
id: "checkout",
label: "Checkout redesign",
tasks: [
{ id: "c1", label: "Card visual", start: day("2026-08-17"), end: day("2026-09-04"), progress: 80 },
{ id: "c2", label: "Address step", start: day("2026-09-01"), end: day("2026-09-25"), progress: 30 },
{ id: "c3", label: "Beta to 5%", start: day("2026-09-28"), end: day("2026-09-28"), milestone: true },
],
},
{
id: "trust",
label: "Trust centre",
tasks: [
{ id: "t1", label: "Sub-processor page", start: day("2026-09-07"), end: day("2026-09-21"), progress: 10 },
{ id: "t2", label: "SOC 2 evidence", start: day("2026-09-21"), end: day("2026-10-16"), progress: 0 },
],
},
]
const LABELS = new Map(GROUPS.flatMap((group) => group.tasks).map((task) => [task.id, task.label]))
export default function GanttChartDemo() {
const [zoom, setZoom] = React.useState<"weeks" | "months">("weeks")
const [selected, setSelected] = React.useState<string>("a2")
return (
<ChartCard
title="Roadmap"
description={LABELS.get(selected) ? `Selected: ${LABELS.get(selected)}` : "Pick a bar"}
className="w-full"
height={280}
actions={
<SegmentedControl
aria-label="Zoom"
size="sm"
options={[
{ value: "weeks", label: "Weeks" },
{ value: "months", label: "Months" },
]}
value={zoom}
onValueChange={(value) => setZoom(value as "weeks" | "months")}
/>
}
>
<GanttChart
aria-label="Roadmap"
groups={GROUPS}
zoom={zoom}
today={TODAY}
selectedId={selected}
onSelect={setSelected}
size="sm"
/>
</ChartCard>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| groups | { id: string; label: string; tasks: GanttTask[] }[] | — | One labelled row-group per project, each holding its bars. |
| GanttTask | { id; label; start: Date; end: Date; progress?: number; milestone?: boolean; tone?: ChartToken } | — | end is the last day of the work and is included in the span, so a one-day task has a span of 1. progress is 0..100. |
| range | { start: Date; end: Date } | the outermost dates in the data | The window the grid covers. Bars are clipped to it; tasks outside it are dropped. |
| zoom | "weeks" | "months" | "weeks" | Whether the head counts weeks (Monday-start) or months, and how wide a day is. |
| today | Date | — | Draws the today line. Left off, no line — and a date outside the range draws none either. |
| onSelect | (taskId: string) => void | — | A bar was picked. Replaces the div's own onSelect. |
| selectedId | string | — | The bar drawn as chosen. Pass it and every bar reports aria-pressed. |
| size | "sm" | "default" | "default" | Row height and label-column width. |
Dependencies
Registry
Source
import * as React from "react"
import { cn } from "@/lib/utils"
import { CHART_TOKENS, type ChartToken } from "@/components/ui/percentage-bar"
const DAY = 86_400_000
/** One bar: a window of whole days, optionally part-done, optionally a marker. */
export type GanttTask = {
id: string
label: string
/** The first day of the work; only its UTC date parts are read. */
start: Date
/** The last day of the work, included in the span. */
end: Date
/** How much of it is done, 0..100. */
progress?: number
/** A moment rather than a stretch: drawn as a marker on `start`. */
milestone?: boolean
/** The hue; without one, the group's own place in the palette. */
tone?: ChartToken
}
export type GanttGroup = { id: string; label: string; tasks: GanttTask[] }
// `onSelect` here takes a task id rather than a DOM select event, so it
// replaces the div prop of that name instead of intersecting with it.
export type GanttChartProps = Omit<React.ComponentProps<"div">, "onSelect"> & {
groups: GanttGroup[]
/** The window the grid covers; without one, the outermost dates in the data. */
range?: { start: Date; end: Date }
zoom?: "weeks" | "months"
/** Where the today line goes. Left off, no line is drawn. */
today?: Date
onSelect?: (taskId: string) => void
/** The task drawn as chosen. Pass it and every bar reports `aria-pressed`. */
selectedId?: string
size?: "sm" | "default"
}
/** How wide a day is at each zoom, in pixels. */
const DAY_WIDTH: Record<"weeks" | "months", number> = { weeks: 15, months: 4 }
const ROW_HEIGHT: Record<"sm" | "default", number> = { sm: 24, default: 30 }
const LABEL_WIDTH: Record<"sm" | "default", number> = { sm: 132, default: 168 }
// Whole class names, so Tailwind finds them: the track carries the hue at a
// quarter strength with a full-strength ring, which is what keeps a bar with
// no progress in it readable against the card it sits on.
const TONE: Record<ChartToken, { track: string; fill: string; mark: string }> = {
"chart-1": { track: "bg-chart-1/25 ring-chart-1", fill: "bg-chart-1", mark: "bg-chart-1" },
"chart-2": { track: "bg-chart-2/25 ring-chart-2", fill: "bg-chart-2", mark: "bg-chart-2" },
"chart-3": { track: "bg-chart-3/25 ring-chart-3", fill: "bg-chart-3", mark: "bg-chart-3" },
"chart-4": { track: "bg-chart-4/25 ring-chart-4", fill: "bg-chart-4", mark: "bg-chart-4" },
"chart-5": { track: "bg-chart-5/25 ring-chart-5", fill: "bg-chart-5", mark: "bg-chart-5" },
"chart-6": { track: "bg-chart-6/25 ring-chart-6", fill: "bg-chart-6", mark: "bg-chart-6" },
"chart-7": { track: "bg-chart-7/25 ring-chart-7", fill: "bg-chart-7", mark: "bg-chart-7" },
"chart-8": { track: "bg-chart-8/25 ring-chart-8", fill: "bg-chart-8", mark: "bg-chart-8" },
}
const MONTH_DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })
const MONTH_DAY_YEAR = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
})
const MONTH_YEAR = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" })
/** Midnight UTC of the day a date names — the grid counts days, not instants. */
function utcDay(date: Date): number {
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
}
/** Whole days from `from` to `to`, both read as UTC days. */
function daysBetween(from: number, to: number): number {
return Math.round((to - from) / DAY)
}
/** The Monday on or before `at`. */
function weekStart(at: number): number {
const weekday = new Date(at).getUTCDay()
return at - ((weekday + 6) % 7) * DAY
}
/** The columns across the head: one per week, or one per month. */
function periodsOf(start: number, days: number, zoom: "weeks" | "months") {
const end = start + (days - 1) * DAY
const out: { key: string; label: string; days: number }[] = []
let cursor = zoom === "weeks" ? weekStart(start) : Date.UTC(new Date(start).getUTCFullYear(), new Date(start).getUTCMonth(), 1)
while (cursor <= end) {
const at = new Date(cursor)
const next =
zoom === "weeks"
? cursor + 7 * DAY
: Date.UTC(at.getUTCFullYear(), at.getUTCMonth() + 1, 1)
// Clipped to the range at both ends, so the first and last columns are as
// wide as the part of them the grid actually shows.
const from = Math.max(cursor, start)
const to = Math.min(next, end + DAY)
out.push({
key: new Date(cursor).toISOString().slice(0, 10),
label: zoom === "weeks" ? MONTH_DAY.format(at) : MONTH_YEAR.format(at),
days: daysBetween(from, to),
})
cursor = next
}
return out
}
/** What a bar reads as: its label, when it runs, and how far along it is. */
function barName(task: GanttTask): string {
const from = utcDay(task.start)
const to = utcDay(task.end)
if (task.milestone) return `${task.label}, milestone, ${MONTH_DAY_YEAR.format(from)}`
const sameYear = new Date(from).getUTCFullYear() === new Date(to).getUTCFullYear()
const window = `${sameYear ? MONTH_DAY.format(from) : MONTH_DAY_YEAR.format(from)} to ${MONTH_DAY_YEAR.format(to)}`
return `${task.label}, ${window}${task.progress === undefined ? "" : `, ${Math.round(task.progress)}% done`}`
}
/**
* Work as bars on a time axis: a sticky column of labels, a scrolling grid of
* weeks or months beside it, and a line where today falls.
*
* Every date is read through its UTC parts, so a bar sits on the same day for
* a reader in Los Angeles and one in Berlin, and each bar carries the offsets
* it was placed at — `data-day-start` and `data-day-span`, in whole days from
* the start of the range — so what the picture says is readable from the DOM.
* The label column is what a screen reader walks; each bar is a button whose
* name carries the dates the position encodes.
*/
function GanttChart({
className,
groups,
range,
zoom = "weeks",
today,
onSelect,
selectedId,
size = "default",
"aria-label": ariaLabel = "Timeline",
...props
}: GanttChartProps) {
const every = groups.flatMap((group) => group.tasks)
const starts = every.map((task) => utcDay(task.start))
const ends = every.map((task) => utcDay(task.end))
const from = range ? utcDay(range.start) : starts.length > 0 ? Math.min(...starts) : utcDay(new Date(0))
const to = range ? utcDay(range.end) : ends.length > 0 ? Math.max(...ends) : from
const days = Math.max(1, daysBetween(from, to) + 1)
const dayWidth = DAY_WIDTH[zoom]
const rowHeight = ROW_HEIGHT[size]
const labelWidth = LABEL_WIDTH[size]
const gridWidth = days * dayWidth
const periods = periodsOf(from, days, zoom)
const todayDay = today === undefined ? null : daysBetween(from, utcDay(today))
const todayInRange = todayDay !== null && todayDay >= 0 && todayDay < days
return (
<div
data-slot="gantt-chart"
data-zoom={zoom}
data-size={size}
data-days={days}
role="group"
aria-label={ariaLabel}
className={cn("w-full min-w-0 text-sm", className)}
{...props}
>
<div data-slot="gantt-scroll" className="overflow-x-auto [scrollbar-width:thin]">
<div className="relative w-fit min-w-full">
<div
data-slot="gantt-head"
className="flex border-b border-rule"
style={{ width: labelWidth + gridWidth }}
>
<div
className="sticky start-0 z-20 shrink-0 bg-card"
style={{ width: labelWidth }}
aria-hidden="true"
/>
<div className="flex" style={{ width: gridWidth }}>
{periods.map((period) => (
<div
key={period.key}
data-slot="gantt-period"
data-days={period.days}
className="type-eyebrow shrink-0 truncate border-s border-border px-1.5 py-1 text-faint-foreground"
style={{ width: period.days * dayWidth }}
>
{period.label}
</div>
))}
</div>
</div>
{groups.map((group, groupIndex) => {
const shown = group.tasks.filter(
(task) => utcDay(task.end) >= from && utcDay(task.start) <= to
)
const tone = CHART_TOKENS[groupIndex % CHART_TOKENS.length]
return (
<div
key={group.id}
data-slot="gantt-group"
role="group"
aria-label={group.label}
style={{ width: labelWidth + gridWidth }}
>
<div
data-slot="gantt-group-label"
className="type-label sticky start-0 z-20 w-fit max-w-full truncate bg-card pt-3 pb-1 text-muted-foreground"
style={{ width: labelWidth }}
>
{group.label}
</div>
{shown.length === 0 ? (
<p className="sticky start-0 z-20 w-fit bg-card pb-2 text-xs text-muted-foreground">
Nothing in this range
</p>
) : null}
{shown.map((task) => {
const startDay = Math.max(0, daysBetween(from, utcDay(task.start)))
const endDay = Math.min(days - 1, daysBetween(from, utcDay(task.end)))
const span = Math.max(1, endDay - startDay + 1)
const clipped = utcDay(task.start) < from || utcDay(task.end) > to
const paint = TONE[task.tone ?? tone]
const progress = Math.max(0, Math.min(100, task.progress ?? 0))
return (
<div
key={task.id}
data-slot="gantt-row"
data-selected={selectedId === task.id || undefined}
// The chosen task is a band across its row — the label
// included, which keeps its own fill for the sticky
// overlap — rather than a halo on the bar.
className="group/gantt-row flex items-center rounded-md data-selected:bg-brand-muted"
>
<div
data-slot="gantt-row-label"
className="sticky start-0 z-20 shrink-0 truncate bg-card pe-3 text-xs group-data-selected/gantt-row:bg-brand-muted"
style={{ width: labelWidth, lineHeight: `${rowHeight}px` }}
>
{task.label}
</div>
<div className="relative shrink-0" style={{ width: gridWidth, height: rowHeight }}>
<button
type="button"
data-slot="gantt-bar"
data-day-start={startDay}
data-day-span={span}
data-milestone={task.milestone || undefined}
data-clipped={clipped || undefined}
aria-label={barName(task)}
aria-pressed={selectedId === undefined ? undefined : selectedId === task.id}
onClick={onSelect ? () => onSelect(task.id) : undefined}
className={cn(
"absolute top-1/2 -translate-y-1/2 rounded-sm focus-ring",
task.milestone
? cn("size-3 rotate-45 rounded-[2px]", paint.mark)
: cn("h-3 overflow-hidden ring-1 ring-inset", paint.track)
)}
// Placed on the inline axis, like the sticky labels
// beside them, so a right-to-left chart runs from
// the right.
style={
task.milestone
? { insetInlineStart: startDay * dayWidth + dayWidth / 2 - 6 }
: { insetInlineStart: startDay * dayWidth, width: Math.max(span * dayWidth - 2, 4) }
}
>
{task.milestone ? null : (
<span
data-slot="gantt-bar-progress"
aria-hidden="true"
className={cn("block h-full rounded-s-sm", paint.fill)}
style={{ width: `${progress}%` }}
/>
)}
</button>
</div>
</div>
)
})}
</div>
)
})}
{todayInRange ? (
<div
data-slot="gantt-today"
data-day={todayDay}
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 z-10 w-px bg-danger"
style={{ insetInlineStart: labelWidth + todayDay * dayWidth }}
/>
) : null}
</div>
</div>
</div>
)
}
export { GanttChart }