Skip to contentVibraUI
Charts

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-chart

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
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 dataThe 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.
todayDate—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.
selectedIdstring—The bar drawn as chosen. Pass it and every bar reports aria-pressed.
size"sm" | "default""default"Row height and label-column width.

Dependencies

Source

components/ui/gantt-chart.tsx
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 }