Skip to content
txbrendan/ui

Gantt Chart

beta

Shows items as bars across a date range, in groups, with a fixed name column, week ticks, and a today marker. Use it to see when work runs and how far along it is.

Preview

Items by stage over six weeks, with today marked

Name
Oct 5 – Nov 15, today Oct 30
To do 3
Reports hub Platform
0/12
Invoice templates Design
1/5
Push alerts Mobile
In progress 4
Payments page Web
45% · 9/20
Mobile app Mobile
60% · 12/20
Onboarding emails Marketing
At risk · 4/10
Card issuing Platform
Late · 7/10
In review 3
Help center Web
90% · 18/20
Team permissions Platform
80% · 8/10
Chat alerts Mobile
1/2
Done 2
Wireframes Design
Done · 8/8
Brand refresh Marketing
Done · 14/14

Loading

Loading

Empty, with a next step

Nothing scheduled yet.

Narrow container: names stay put while the dates scroll

Name
Oct 12 – Nov 13, today Oct 30
In progress 4
Payments page Web
45% · 9/20
Mobile app Mobile
60% · 12/20
Onboarding emails Marketing
At risk · 4/10
Card issuing Platform
Late · 7/10

Install

  1. 1. Add the registry to components.json, once per app. Without it, the shadcn CLI looks up @txbrendan in a public list that txbrendan/ui does not control.

    npx shadcn@latest registry add "@txbrendan=https://txbrendan.dev/r/{name}.json"
  2. 2. Add the theme, once per app. It replaces your color tokens with ones checked for contrast.

    npx shadcn@latest add @txbrendan/theme
  3. 3. Add the component.

    npx shadcn@latest add @txbrendan/gantt-chart

Use it for

  • Work that spans days or weeks, like projects by stage, where start, end, and progress matter.
  • Pass groups, each with its items. An item takes a label, start and end days, and progress from 0 to 1.
  • Pass days as ISO strings, like '2026-10-13'.
  • Pass onItemOpen so each bar is a button that opens its item, like a Sheet.
  • Put a caption on a bar for a short count, like '45% · 9/20'. It shows beside the bar, so the fill reads whole.
  • The chart has loading and empty states. A failed load is yours: show a Notice with a retry in its place.
  • To build your own rows, like a list that turns into this chart, wrap them in GanttScale and use GanttHeader, GanttTrack, GanttGrid, and GanttToday for the date side.

Avoid it for

  • Times within one day, like bookings. Use Time Grid.
  • A list of events with no length. Use Timeline.
  • One value over time. Use LineChart or AreaChart.
  • Dragging bars to new dates. Bars open, they do not move.

Props

  • groups

    The rows, in groups, like stages. Each group shows its title and count, then its items. A group with no items still shows its title.

    { id: string; title: ReactNode; items: GanttItem[] }[]

  • GanttItem

    One row. The bar runs from the start of start to the end of end. progress, from 0 to 1, fills that share in tone, neutral by default. caption shows beside the bar, and defaults to the progress as a percent.

    { id: string; label: string; meta?: ReactNode; start: string | Date; end: string | Date; progress?: number; tone?: "neutral" | "brand" | "danger" | "success" | "warning"; caption?: ReactNode }

  • aria-label

    Names the chart for screen readers.

    string

  • start

    The first day shown.

    string | Date · default the earliest item's start

  • end

    The last day shown.

    string | Date · default the latest item's end

  • today

    Places the today marker, a pill with the date in the header and a line down the chart. Left out, there is no marker. Pass it from the server, so the first render matches.

    string | Date

  • locale

    The locale for dates and percents. One it does not know falls back to en-US.

    string · default "en-US"

  • timeZone

    The time zone that puts a Date, or a string with a time, on its day. ISO days, like '2026-10-13', stay as they are. One it does not know falls back to UTC.

    string · default "UTC"

  • loading

    Shows placeholder rows while the items load.

    boolean · default false

  • empty

    What shows when no group has items. Give it a next step, like a button that adds one.

    ReactNode · default "Nothing scheduled yet."

  • onItemOpen

    Makes each bar a button that opens its item, and lets a press on its row open it. Called with the item's id.

    (id: string) => void

  • GanttScale

    Part. Holds start, end, today, locale, and timeZone for the parts inside, and sets the layout variables. Put it around your own scroll box and rows. It is a size container.

    GanttScaleProps

  • GanttHeader

    Part. The dates along the top, a tick at each Monday. A tick near today gives way to the today pill. The right cell of the header row.

    GanttHeaderProps

  • GanttTrack

    Part. The right cell of one row, with its item's bar. Takes item, index (the row's place from the top across every group, which sets when its bar arrives), and onItemOpen.

    GanttTrackProps

  • GanttGrid

    Part. Faint week lines over the date side. Put it first inside the positioned box that holds the rows.

    GanttGridProps

  • GanttToday

    Part. The today marker over the date side. It arrives last: a pill with the date in the header, then the line draws down over the bars. Put it beside GanttGrid.

    GanttTodayProps

  • className

    Classes for the root. The layout reads variables you can set here: --gantt-head-h (2.5rem), --gantt-group-h (2.5rem), --gantt-row-h (2.75rem), --gantt-name-w (12rem, down to 8.5rem in a narrow box), --gantt-day-w (0.625rem, the least width of a day; a wider box stretches the days), and --gantt-bg (the page color, behind the fixed names).

    string

Accessibility

  • The chart is a region named by aria-label, around a table: a header row, a row group per group with its title as a row header, and a row per item with its name as the row header.
  • With onItemOpen, each bar is a button named by its item, its dates, and its progress, like 'Payments page, Oct 19 – Nov 6, 45% done'. Focus scrolls it clear of the fixed names.
  • Without onItemOpen, each row's cell says the same in hidden text. When the dates scroll, the scroll box is one Tab stop, so a keyboard can scroll them. With buttons inside, it is no stop of its own.
  • Hover or keyboard focus on a bar shows its name, dates, progress, and caption in a tooltip. One tooltip shows at a time.
  • A name cut off by the column shows in full in a tooltip on hover, or on a tap when the bars are not buttons. Screen readers read the whole name.
  • The week lines and the today marker are hidden from screen readers. The header says the range and today's date.
  • In forced colors, each bar keeps an outline, the done part and the today marker take the text color, the today line widens, and the week lines turn GrayText, so today stays the strongest line.
  • With reduced motion, bars and the today line fade in place instead of growing.

Examples

Items by stage, with today marked

<GanttChart
  aria-label="Work by stage"
  start="2026-10-05"
  end="2026-11-15"
  today="2026-10-30"
  groups={[
    {
      id: "doing",
      title: "In progress",
      items: [
        { id: "payments", label: "Payments page", meta: "Web", start: "2026-10-19", end: "2026-11-06", progress: 0.45, tone: "brand", caption: "45% · 9/20" },
        { id: "help", label: "Help center", meta: "Web", start: "2026-10-13", end: "2026-10-31", progress: 0.9 },
      ],
    },
  ]}
  onItemOpen={(id) => openItem(id)}
/>

Dates in another locale, with Date values put on their day in a time zone

<GanttChart
  aria-label="Work by stage"
  locale="de-DE"
  timeZone="Europe/Berlin"
  today={new Date("2026-10-30T09:00:00Z")}
  groups={[
    {
      id: "doing",
      title: "In progress",
      items: [{ id: "payments", label: "Payments page", start: new Date("2026-10-19T08:00:00Z"), end: new Date("2026-11-06T17:00:00Z") }],
    },
  ]}
/>

Your own name column, with the date side from the parts

<GanttScale start="2026-10-05" end="2026-11-15" today="2026-10-30" locale="en-GB" timeZone="Europe/London">
  <div className="overflow-x-auto">
    <div role="table" aria-label="Work" className="relative w-max min-w-full">
      <GanttGrid />
      <GanttToday />
      <div role="row" className="grid h-(--gantt-head-h) grid-cols-[var(--gantt-name-w)_minmax(0,1fr)]">
        <div role="columnheader" className="sticky start-0 z-10 bg-(--gantt-bg)">Name</div>
        <GanttHeader />
      </div>
      {items.map((item, index) => (
        <div key={item.id} role="row" className="grid h-(--gantt-row-h) grid-cols-[var(--gantt-name-w)_minmax(0,1fr)]">
          <div role="rowheader" className="sticky start-0 z-10 bg-(--gantt-bg)">{item.label}</div>
          <GanttTrack item={item} index={index} onItemOpen={openItem} />
        </div>
      ))}
    </div>
  </div>
</GanttScale>