Charts

Chart Gantt

A four-state schedule chart — lanes that pack overlapping work into sub-rows, an axis that follows the span from minutes to years, progress-filled bars, finish-to-start arrows that flag the dependencies the dates do not honour, and a now line from an injected instant.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, CalendarRange, RefreshCcw } from "lucide-react"

import { cn } from "@/lib/utils"
import { useResizeObserver } from "@/registry/hooks/use-resize-observer"
import {
  buildGanttLayout,
  type ChartGanttData,
  type GanttLayoutBar,
  type GanttLayoutLane,
  type GanttTick,
  MS_PER_DAY,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-gantt.json

Prompt

Build a React + TypeScript + Tailwind "ChartGantt" card — hand-rolled SVG plus
zod, with the layout maths in pure exported functions beside the component.
Recharts is not used: it has no interval-packed categorical row axis, and the
packing is the part that has to be testable.

Contract
- One zod schema is the source of truth:
  { status: "loading" | "empty" | "error" | "ready"; title: string;
    description?: string; instant?: string;
    lanes?: { id: string; label: string }[];
    items: { id: string; label: string; lane?: string; start: string;
             end?: string; progress?: number; kind?: "task" | "milestone";
             dependsOn?: string[] }[] }.
  `start` / `end` are ISO 8601 days or datetimes; `end` omitted means a
  zero-length checkpoint. `progress` is a 0-1 fraction, and *omitted* is not
  0 — it means completion is not tracked, which the chart says out loud.
- Dates are deliberately NOT refined in the schema. A feed with a malformed
  date is a fact about the feed; the chart's job is to draw the rest and
  count what it could not place, not to blank the card at parse time.
- `instant` is the "now" marker, INJECTED. The component never calls
  Date.now() or new Date() while rendering: a chart that reads the clock
  paints a different picture on the server than in the browser, and can
  never be snapshot-tested.
- Component props = z.infer of the schema plus density ("comfortable" |
  "compact"), dependencies (default true), range ({ start, end } ISO pair,
  omit to fit the data), padding (default 0.04, clamped 0-0.25), minBarWidth
  (default 3, clamped 1-12), locale (default "en-US"), unassignedLabel,
  onItemSelect, onRetry, emptyState and className. forwardRef to the card,
  spread the remaining native props onto it.
- Export the maths as pure functions: parseIsoInstant, daysFromCivil /
  civilFromDays, buildTimeTicks, packLaneRows, buildGanttLayout returning
  { ok: true, layout } | { ok: false, issue }, plus stackLanes,
  placeBarLabel and dependencyPath beside the component. A test must be able
  to print the same numbers the picture is made of.

Behavior
- TIME PARSING is integer arithmetic against the proleptic Gregorian
  calendar (Hinnant's days_from_civil), never `new Date(string)`. A datetime
  with no offset is read as UTC. This is the one decision that keeps SSR and
  the client on the same pixel: `new Date("2026-03-02T09:30")` is a
  different instant in every timezone, so it hydrates wrong. Round-trip the
  civil date to reject impossible ones (2026-02-30) instead of silently
  drawing a bar one day late; unparseable rows are dropped AND COUNTED.
- LANE PACKING is the layout. Items are grouped by lane, then packed with a
  greedy first-fit over start-sorted intervals: a lane grows a second
  sub-row only where two of its items really overlap in time. Sorting by
  start is what makes first-fit optimal here — the number of rows it opens
  equals the maximum number of items alive at any instant, which is the
  floor for any packing. Neighbours must leave a gap (about 1.2% of the
  span) or a task ending exactly when the next begins is drawn as one
  continuous bar and reads as one task. A milestone has no width, so it
  claims a small slot while packing or the next bar starts on top of the
  diamond.
- DOMAIN: fitted to the data by default, padded 4% each side, and always
  widened to include the injected instant — a window that hides the now line
  answers "are we behind?" by removing the only mark that says so. `range`
  fixes the window instead: items entirely outside it are dropped and
  counted, items that straddle an edge are clipped and notched on that edge.
  A degenerate domain (one instant, or a single milestone) opens to a
  one-day window, because a zero-wide domain turns every fraction into NaN.
  A range whose end is not after its start is refused with a message, not
  drawn mirrored.
- AXIS: the step comes from a ladder (1/5/15/30 min, 1/3/6/12 h, 1/2/7/14 d,
  then 1/3/6/12/24/60/120 months), and the rung is the one whose tick count
  is CLOSEST IN LOG SPACE to the target of one tick per ~108px — not the
  first rung above the ideal spacing, which lands up to 3x too sparse
  because the ladder jumps 2-3x per rung (an eight-hour window asking for
  six ticks gets three-hourly ticks and shows two of them). Ticks land on
  CALENDAR BOUNDARIES: sub-day steps snap to midnight, weekly steps to
  Monday, monthly steps to the 1st. Ticks at "start + k · step" would read
  07:13, 19:13, 07:13 and nobody can use that. A tick that crosses a bigger unit (a midnight among hours, a 1st
  among days) is major: stronger gridline, and for sub-day steps its label
  becomes the date rather than the time.
- LAYOUT NUMBERS ARE FRACTIONS. buildGanttLayout returns x0/x1 in 0-1 of the
  domain; the component multiplies by the measured plot width. That keeps
  the maths independent of measurement, and the same layout is correct at
  any width. Width comes from a ResizeObserver with a fixed fallback for the
  first paint, so SSR and the first client render agree; the pane scrolls
  sideways below a minimum rather than shrinking the labels.
- MINIMUM BAR WIDTH: a two-hour task inside a five-month plan is 0.4px —
  invisible and impossible to point at. Bars are floored at minBarWidth. Be
  honest about the trade: below the floor the width no longer encodes the
  duration, so the footer counts how many bars are drawn at the floor and
  the exact dates stay in the readout and in the table.
- OVERDUE is derived, never a field: a task whose end is before the instant
  with a TRACKED progress below 1. An item that reports no progress is never
  called overdue — "not tracked" is not "not done" — and a milestone never
  is either, since it has no remaining work to be behind on; it has been
  reached or it has not. It is drawn as a hatch over the unfinished
  remainder, not as a colour swap.
- DEPENDENCIES are finish-to-start elbows from the predecessor's right edge
  into the successor's left edge, with an arrowhead marker. When the
  successor starts BEFORE the predecessor finishes, the plan cannot hold:
  draw that arrow dashed and in the destructive token, count it in the
  header, and label it in the table. A dependency pointing at an item that
  is not drawn is counted, not drawn into the void. Elbows are painted under
  the bars so an elbow crossing a bar passes behind it.
- The four states are first-class branches of one bg-card panel: a
  deterministic aria-hidden skeleton, an empty state that still prints how
  many rows were skipped, an error state carrying either the transport
  message or the specific layout refusal plus a "Try again" button only when
  onRetry exists, and ready. status="ready" with nothing drawable falls
  through to the empty branch — with the counts.

Rendering & styling
- Semantic tokens only. A lane's bars take var(--chart-1..5) by lane index;
  the track is the same token at 22% with the solid progress segment painted
  on top, so the boundary between done and not done is a hard edge you can
  read off the axis. Colour never carries a lane alone: the lane name is frozen in the
  left gutter beside every row it owns, every bar is labelled, milestones are
  diamonds, overdue is a hatch, conflicts are dashed, and an sr-only table
  repeats all of it as text.
- Bar labels get four candidate slots in order — inside the bar, the gap to
  its right, the gap to its left, then whichever is roomiest with the text
  elided into it. The right-hand bound is the NEXT BAR IN THE SAME ROW, not
  the edge of the plot: a label that runs under its neighbour is worse than
  an ellipsis. Draw the text with paint-order="stroke" and a var(--card)
  stroke — the SVG halo — so a name stays readable over a fill, a lane band
  or a connector.
- Axis and tick text is muted-foreground at 11px, gridlines use the border
  token, majors the muted-foreground token at low opacity. The now line is a
  dashed foreground rule with a haloed "now" label, pointer-events none, and
  it is simply absent (and said to be absent, in the summary) when no
  instant was injected or it falls outside the window.
- Motion: the only animation is the hover/focus opacity transition and the
  skeleton pulse, both gated with motion-reduce. The chart is complete and
  readable with animation off.
- Accessibility: the plot is one tab stop with role="listbox" and
  aria-activedescendant, each bar a role="option" with a full sentence as
  its accessible name — SVG child focus is unevenly supported, and 40 bars
  would otherwise be 40 tab stops. Keyboard map: Left/Right walk one lane in
  time order, Up/Down jump to the nearest bar in the lane above or below
  (nearest by start, so the eye lands where it was looking), Home/End go to
  the ends of the lane, Ctrl/Cmd widens that to the whole plan, Enter or
  Space fires onItemSelect. The walk clamps rather than wraps: a schedule
  has a first task and a last one. Under the plot, a visible readout line
  (aria-hidden, because the focused option already announces itself) and an
  sr-only role="status" fed ONLY by pointer moves, which are the ones focus
  does not announce. Then an sr-only wrapper div holding one real table:
  item, lane, start, end, duration, progress, state and what it waits on.
  Put sr-only on the wrapper DIV, never on the table — CSS width is only a
  lower bound for a table box, so width:1px does not hold one back and a
  narrow viewport picks up hundreds of px of horizontal scroll.
- A bar is only cursor-pointer when onItemSelect is passed; nothing renders
  a clickable affordance it has not been given a handler for.

Customization levers
- density: "compact" halves the air for a sidebar; the geometry is
  unchanged. Row height, bar height and lane gap are one table at the top —
  a third density is three numbers.
- dependencies={false} drops the connectors when the ordering is already
  understood and the bars are the point. Keep the conflict count in the
  header even then, or you lose the only warning.
- range vs fitted: fix the window when two charts have to be comparable, fit
  it when the plan is read on its own. padding controls how much air a
  fitted window leaves.
- minBarWidth: 0-ish for strictly proportional bars when the shortest and
  longest items are within about 200:1, 6-8 when the short items matter more
  than the arithmetic.
- Palette: bars key off var(--chart-N) by lane index — swap that for a
  colour-by-status map (late / at risk / done) when the lane is already
  obvious from the gutter, and keep a non-colour channel for whichever
  distinction the colour stops carrying.
- Axis: the step ladder and the ~108px per tick are the two numbers that
  decide the axis density; add a "quarter" step for multi-year plans.
- Interaction: onItemSelect is yours to wire to a drawer or a route. To pin
  a highlight instead, keep a pinned index beside the hovered one and let
  hover beat keyboard beat pin, the way the readout already resolves them.

Concepts

  • Lane packing — the layout decision that separates this from a schedule table. Items are grouped by lane and packed greedily over start-sorted intervals, so a lane is one row high until two of its items genuinely overlap and only then grows a sub-row. The row count it produces equals the maximum number of items alive at any instant, which is the fewest rows any packing could use.
  • Injected instant — the now line comes from a prop, never from the clock. That is what makes the picture identical on the server and in the browser, snapshot-testable, and honest in a replay of last quarter; when it is missing, the chart says so instead of quietly substituting today.
  • Dependency conflict — a finish-to-start arrow whose dates run backwards: the successor starts before the thing it waits on has finished. Most Gantt charts draw that elbow exactly like a healthy one. Here it is dashed, in the destructive token, counted in the header and flagged in the table, because it is the single most useful thing the plan can tell you.
  • Calendar-boundary ticks — the axis snaps to midnight, to Monday, to the 1st, and picks its step from a ladder sized to the pixel width. Ticks placed at start + k · step land on 07:13 and 19:13 and make the chart unreadable no matter how correct the arithmetic is.
  • Counted drops — an unparseable date, a repeated id, a row outside the window, a dependency on an item nobody drew: each is removed from the picture and added to a line under the header. A chart that silently omits rows is a chart that lies about how much work there is.
  • Minimum bar width — a floor that buys a pointable target at the cost of proportionality: below it, width no longer encodes duration. The footer counts how many bars are drawn at the floor, and the exact dates stay in the readout and in the sr-only table.

On This Page