Critical Path
A four-state task dependency board that derives the schedule from durations and links alone — a forward and backward pass, total and free float per task, the zero-float route drawn in the one accent colour, and a cyclic plan named rather than walked forever.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-critical-path.jsonPrompt
Build a React + TypeScript + Tailwind "ChartCriticalPath" card with zod. It is
not a Gantt chart with arrows: a Gantt draws a schedule somebody already
decided, this one is HANDED DURATIONS AND LINKS AND DERIVES THE SCHEDULE. The
product is the two derived numbers — how long the plan takes, and how far each
task can slip before the plan does — so a build that draws the graph and skips
the float has missed the point.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string; errorMessage?: string;
tasks: { id: string; label: string; duration: number;
dependsOn?: string[]; owner?: string }[];
startDate?: string }.
- duration is in days and non-negative. Zero is meaningful: it is a milestone,
a gate that takes no time but still waits for its predecessors.
- dependsOn holds finish-to-start predecessors, the only link type modelled.
Every id must name a task in the same array.
- startDate is a CALENDAR DAY, "YYYY-MM-DD", not an instant. Parse it field by
field into UTC midnight, round-trip it back through getUTC* so "2026-02-30"
is rejected rather than silently becoming 2 March, and derive every printed
date as that anchor plus a whole number of 86 400 000 ms formatted in UTC.
Never slice an ISO string to display a day, and never build a date from one
value and print another — that is how a plan prints the wrong day for every
reader east of you and again on the far side of a daylight-saving boundary.
With no startDate, report in day offsets ("day 0 to day 38") rather than
invent a calendar.
- Component props = z.infer of the schema plus tightFloatDays (default 2,
clamped 0-365), nodeWidth (default 156, clamped 120-260), showFloatBars,
showFloatMix, formatDuration, onRetry, emptyState, className and the native
div props through forwardRef.
- Ship a pure module beside the schema: inspectCriticalPathData() for the
structural pass, buildCriticalPathLayout() for the schedule, isCriticalLink(),
chainThrough(), percentileOf(), apportionPercents(), parsePlanStart(),
planDate() and lastWorkedDay().
Behavior — the maths, which is the product
- REFUSE BEFORE YOU SCHEDULE. In this order: duplicate ids (dependencies
resolve by id, so one task would inherit the other's predecessors), a task
depending on itself, a link pointing at an id that is not in the plan
(dropping it shortens the critical path and the chart would report a finish
date the team cannot hit), a non-finite or negative duration, and finally a
cycle. Each refusal names the tasks involved and renders in the error branch.
- A CYCLE IS A FIRST-CLASS ANSWER, NOT A HANG. Find it with an ITERATIVE
depth-first colouring (white / on-the-current-path / finished) over an
explicit stack — recursion blows the JS stack on a deep generated plan — and
when a link lands back on the path you are standing on, slice the stack from
that task to report the loop IN EDGE ORDER. Say plainly why there is no
answer: a critical path is the longest route through a plan, and a loop has
no longest route because every lap round it is longer than the last.
- Topological order by Kahn's algorithm. Each task enters the queue at most
once, so it terminates on any graph including a cyclic one, where it simply
stops early. If a cycle reaches the scheduler anyway, append the leftovers in
input order — a meaningless board beats a hung tab.
- FORWARD PASS: earliest start is the largest earliest-finish among the
predecessors; earliest finish is that plus the duration; the plan's duration
is the largest earliest finish anywhere. Compute the longest-path rank in the
same sweep — that rank is the board column, and it is what makes every link
point rightwards.
- BACKWARD PASS: latest finish is the smallest latest-start among the
successors, or the plan's finish for a task nothing waits on; latest start is
that minus the duration.
- TOTAL FLOAT = latest start − earliest start: how far a task can slip before
the PLAN slips. FREE FLOAT = the earliest start of the soonest successor
minus this task's earliest finish: how far it can slip before the NEXT TASK
slips. Report both; they differ exactly where a delay is absorbed by a queue
rather than by the schedule, and that difference is the finding.
- Zero float is the critical path. Compare against an epsilon, not against an
exact zero: fractional durations make latestStart − earliestStart land on
4.44e-16 and an exact test would leave the chart with no critical path at all.
- A LINK IS CRITICAL ONLY IF BOTH ENDS ARE CRITICAL *AND* THE SUCCESSOR STARTS
THE INSTANT THE PREDECESSOR FINISHES. Two critical tasks can sit either side
of a link with room in it — the successor is held up by a different
predecessor — and painting that link accent draws a route the delay does not
travel along. Ties are real: two routes of equal length are both critical, so
colour every zero-give link and let prose name just one of them.
- Bucket the tasks by float into critical / tight (≤ tightFloatDays) /
comfortable and print the split as whole percentages that sum to EXACTLY 100,
by largest remainder with ties going to the earlier bucket. Rounding each
share on its own prints 99 or 101 and a strip that does not add up is the
first thing a reader notices.
- Every denominator is guarded: an empty plan apportions to zeros instead of
dividing by a total of 0; a plan of all milestones has a duration of 0, so the
bar scale falls back to 1 and each bar collapses to its minimum tick; a
single task is its own critical path; five tasks with no links at all are one
column of five rows, where the critical path is just the longest task.
- Percentiles state their method: median float by linear interpolation between
order statistics (R-7 / PERCENTILE.INC). The position is fractional, the index
never is — sorted[7.5] is undefined and undefined formats as "NaN days".
- EARLIEST FINISH IS A BOUNDARY, NOT A DAY. A task starting on day 17 and
lasting nine days finishes at the instant day 26 begins, so the last day
anybody touches it is day 25. Print the boundary as the finish date and every
task in the plan looks a day longer than it is.
- The four states are first-class branches of one bg-card panel: a fixed
skeleton silhouette (aria-hidden, plus an sr-only role="status"), an empty
state, an error state carrying either the transport message or the specific
contract refusal plus a "Try again" button only when onRetry exists, and
ready. status="ready" with no tasks falls through to the empty copy.
Rendering & styling
- Layout is ARITHMETIC, not measurement: column pitch = nodeWidth + gap, row
pitch = card height + gap, so the board's size is known before paint. No
ResizeObserver, no animation frame, no global listener and therefore nothing
to tear down — which is also why it renders identically on the server.
- ONE ACCENT, ONE FOCAL THING. var(--chart-1) marks the critical path and
nothing else: the rail down a critical card, the filled part of its slack bar,
the zero-give links, the first segment of the mix strip. Everything else is
bg-card / bg-muted / border / text-muted-foreground. Hierarchy comes from size
and weight — an oversized numeral for the plan length, a small label, a muted
caption — not from boxing every reading in its own card.
- Colour is never the only encoding: a critical card is also bolder, its float
line reads "On the critical path" in words, and the sr-only table has a column
for it.
- Cards sit on a soft bg-muted ground, the links are drawn UNDER them in one
SVG so a link spanning several columns passes behind an intervening card and
re-emerges on the same line instead of drawing over it. Cubic curves with
HORIZONTAL control points, plus a small filled arrowhead at the target so the
direction of the dependency is explicit.
- Each card carries a mini-timeline of the whole plan on one shared scale:
offset = earliest start, a solid segment as long as the duration, then a
HATCHED segment as long as the total float. Position says when the task sits,
length says how long it takes, hatching says "room to move, not work being
done". A milestone is a tick; every width is clamped so nothing paints past
the track.
- Rows are ordered critical-first inside each column, so the path reads as a
line across the top and everything with room hangs below it.
- The board is horizontally scrollable and the card never overflows its
container; min-w-0 the whole flex chain or the panel stretches instead.
- Accessibility contract: a <figure> whose sr-only <figcaption> is the actual
finding — plan length, the route, how many tasks sit on it, who has the most
room, the median float and the stated method. Each task is a real <button>
with aria-pressed, reachable by Tab, with a focus-visible ring. Its
aria-label OPENS WITH THE EXACT STRINGS THE CARD PRINTS (label, meta line,
float line) before adding what only the geometry shows, so the visible label
is contained in the accessible name (WCAG 2.5.3). The SVG is aria-hidden and
focusable="false". Below the board an sr-only WRAPPER DIV holds a real table,
one row per task. Put sr-only on the wrapper, 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 375px viewport picks up hundreds of px of horizontal scroll.
- Interaction: hovering a card previews the chain a delay would travel — every
ancestor and descendant, found by a BREADTH-FIRST walk with a seen set so it
terminates on any graph — and clicking pins it. Everything outside the chain
dims. Escape clears the pin. The visible readout line is aria-hidden and
mirrored into an sr-only role="status" that speaks ONLY for a pinned task: a
hover a screen-reader user never made must not talk.
- Motion: the only animation is the skeleton pulse and an opacity transition on
dimming, both with motion-reduce variants. Nothing about reading the chart
depends on motion.
Customization levers
- tightFloatDays moves the line between "nearly critical" and "comfortable"
without touching the maths — 1 for a tight release train, 5 for a quarter.
- nodeWidth is the density dial (120 for a dense board, 260 when task names are
long); the card height is fixed so the label always gets its two clamped
lines.
- showFloatBars={false} keeps every float in the readout, the footer and the
table but stops drawing the per-task window — use it for a dashboard tile
where the card is a picture with a caption. showFloatMix={false} drops the
strip the same way.
- formatDuration re-points every printed duration ("1.5 wk", "12 h") without
touching the schedule; the hero numeral stays a bare number with a fixed
"days" label by design.
- startDate is the calendar switch: supply it for real dates, omit it for day
offsets. Durations are calendar days — feed working-day durations through your
own calendar first if the plan has to skip weekends.
- Palette: re-point var(--chart-1) to change what "critical" looks like. Adding
a second hue is the one change that costs you the design — the whole card is
built so exactly one thing is loud.
- Interaction: the readout is deliberately one line and there is no tooltip.
Lift the pinned id into a parent to drive a detail panel beside the board, or
wire the card's onClick to open the ticket.Concepts
- Forward and backward pass — one sweep in topological order gives every task the soonest it can start; one sweep back gives the latest it can start without moving the finish date. The chart exists in the gap between those two answers, which is why it needs durations and links rather than the dates a Gantt is handed.
- Total float versus free float — total float is how far a task can slip before the plan slips; free float is how far it can slip before the next task slips. They diverge exactly where a delay would be absorbed by a queue instead of by the schedule, so a task with nine days of total float and none free is a task whose slip is somebody else's problem immediately.
- Zero-give link — a link is only critical when both ends have no float and the successor starts the instant the predecessor finishes. Two critical tasks either side of a link with room in it are common — the successor is held up by a different predecessor — and colouring that link would draw a route the delay never travels along.
- A cycle has no longest route — the critical path is the longest way through a plan, so a loop has no answer at all: every lap round it is longer than the last. The honest response is to name the tasks holding each other up, in order, rather than to pick an arbitrary entry point and draw something plausible.
- Ties are real — two routes of exactly equal length are both critical. Prose has to name one of them, but the picture must not arbitrate: every zero-give link is drawn in the accent, so the reader sees that there are two ways to be late rather than one.
- Chain preview — hovering a task lights everything upstream and downstream of it, found by a breadth-first walk with a seen set, and dims the rest. That is the set a day lost here would travel through, which is a different question from "is this task on the critical path" and the one people actually ask before they move a date.
WIP Limits
A four-state work-in-progress card: every board column read against its own limit, with a breach as an explicit state and a column that was never capped kept out of the verdict entirely.
Roadmap Swimlanes
A four-state roadmap board — epics as bars across coarse time buckets in per-team lanes, deliberately without dates or dependency arrows, leading on how loaded one quarter is and flagging the lanes that are over the capacity they stated.