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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-roadmap-swimlanes.jsonPrompt
Build a React + TypeScript + Tailwind "ChartRoadmapSwimlanes" roadmap board
with zod. No chart library and no date library: lanes and time buckets are one
CSS grid, and an epic is a grid item spanning the columns it claims. Ship it as
three files — component, zod contract, seeded mock.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready";
plan: { label, currentBucketId? };
buckets: { id, label, sublabel? }[];
teams: { id, name, capacity? }[];
epics: { id, teamId, title, startBucketId, endBucketId?,
confidence: "committed" | "likely" | "exploring",
owner?, note? }[] }.
- Component props = z.infer of the schema plus title, barHeight, bucketNoun,
bucketNounPlural, showBucketFocus, defaultFocusBucketId, onRetry, emptyState
and className. No hand-written parallel interface.
- A bucket is a LABEL, not a date range, and an epic is placed by naming the
buckets it starts and ends in. That is the whole point of the chart: the plan
is coarse on purpose, so there is no clock to read, no timezone to pick, no
DST edge and no ISO string sliced into a day that is off by one for half the
readers. Quarters, halves, Now / Next / Later and named programme increments
are all the same shape. Which column is "now" is stated in
plan.currentBucketId — the component never calls Date.now().
- confidence is the only per-epic quality carried: how firm the date is, not
how far along the work is. Per-cent complete and blockers belong on the
delivery board this roadmap links out to.
Behavior
- A pure layout builder turns the payload into blocks carrying bucket indices
and a sub-row, so the same layout is right at any bar height and testable
without rendering. Lanes pack their epics FIRST FIT: the earliest sub-row
whose last epic ended in a strictly earlier bucket. Buckets are inclusive, so
two epics sharing a quarter never share a row, and a lane is exactly as tall
as the rows it needs.
- Capacity is the one contradiction a coarse plan can still check about itself:
count the epics occupying each lane-bucket and flag any bucket where the
count exceeds the team's stated capacity. Flagged cells take a destructive
wash and underline, and the worst one is named in a sentence under the board
("Growth carries 3 epics in Q3 2026 against a capacity of 2"). A team that
states no capacity — or a non-positive one — is simply never flagged.
- One focus column at a time. The column headers are a radiogroup with roving
tabindex; arrows wrap, Home/End reach the ends, and focus follows selection.
Choosing a column re-reads the headline off that column only — epic count,
the committed / likely / exploring mix, how many were carried in from earlier
and whether it is the busiest column of the plan — while every bar stays
exactly where it was. The choice is held as an id and resolved each render,
so a payload that drops that column falls back instead of pointing the
headline at nothing.
- Lane shares are whole per cents of the plan's epic-quarters, apportioned by
LARGEST REMAINDER so they sum to exactly 100. Lanes of 5, 5, 5 and 6 of 21
print 24 / 24 / 24 / 28; rounding each share on its own prints 101.
- Four first-class branches: loading (a skeleton in the ready silhouette —
headline numeral, column header, four lanes of bars), empty (a swimlane glyph
plus copy explaining what a bar means), error (message + retry when onRetry
exists), ready. A ready payload with nothing placeable falls back to the zero
state and says how many rows it lost and why.
- Nothing is dropped in silence: repeated ids, unknown teams, unknown buckets
and an end bucket before its start are each counted and reported under the
board. An omitted endBucketId is a one-bucket epic; a NAMED end that resolves
to nothing is a broken row, not a one-bucket epic.
- Every printed number goes through one finite guard before formatting, and
every denominator is checked: a plan with no epic-quarters returns zero
shares rather than dividing by zero.
Rendering & styling
- Semantic tokens only, and colour is spent sparingly. Each lane takes
var(--chart-N) once and spends it three ways — the lane dot, the bar's tint
and the bar's full-strength left rail — so a low-alpha wash is never the only
carrier of colour on a near-black card. Everything else is bg-card / border /
muted-foreground.
- Exactly one saturated accent per view: the focus column, marked by a primary
top rule, a primary column label and a bg-primary/5 wash down the board.
Destructive is reserved for over-committed cells and their sentence.
- Confidence is a ramp of treatment, not a second palette: committed is a solid
border with the strongest tint, likely is the same border with a light tint
and a dimmed rail, exploring is a dashed border with no tint and a muted
rail. The key beside the headline shows the three treatments in neutral
swatches so it can never be mistaken for a lane colour.
- Hierarchy comes from size and weight, not from boxes: one oversized numeral
(the focus column's epic count), one small label, one muted caption, and no
card around any of them.
- Bars degrade by room rather than clipping: the owner name is drawn only on
bars spanning two or more buckets and only when the bar is tall enough to
seat it; the title truncates.
- One tab stop for the whole board — the column radiogroup. The lanes are
aria-hidden because a swimlane read linearly is a maze; the same facts reach
a screen reader in reading order from an sr-only summary and a full table of
every epic with its lane, span, confidence, owner and notes.
- The board scrolls horizontally below its natural width (lane column plus a
minimum per bucket) instead of crushing a column below an epic title.
- The only animation is the loading pulse, and it is disabled under
prefers-reduced-motion. There is no timer, no observer and no rAF to clean
up: the whole chart is derived state.
Customization levers
- Density: barHeight (20–44) is the one scale knob — it sets bar height, lane
height and whether owner names are drawn. Change MIN_BUCKET_PX to trade
columns against horizontal scrolling, LANE_COLUMN_PX to fit longer team
names.
- Vocabulary: bucketNoun ("quarter", "sprint", "PI") rewrites every sentence,
with bucketNounPlural for the units an "s" gets wrong ("half" → "halves");
the columns print their own labels, so a Now / Next / Later board needs no
schema change. Swap "epic" the same way if your unit is a bet or an
initiative.
- Which sub-blocks: the headline, the confidence key, the over-capacity line
and the footnote are independent — drop any of them for a bare board without
touching the lanes.
- Which column is the subject: showBucketFocus={false} pins the headline to
defaultFocusBucketId (or plan.currentBucketId) and drops the radiogroup, for
a read-only slide.
- Colour keys: the tone index cycles five chart tokens by lane order; swap the
one toneOf call for a fixed token per team id to carry brand colours, or key
the tone off confidence instead of lane if the roadmap is about certainty.
- Drill-in: bars are deliberately not buttons. To open an epic, give the bar
role="button", a tabIndex and an onSelect — the layout already carries the
id, the lane and the bucket indices.
- The opinion: capacity is one comparison in the builder. Replace it with story
points per lane-bucket, a headcount ratio, or drop it entirely for a plan
that makes no claim about load.Concepts
- A bucket is a label, not a date — the columns carry the coarseness the roadmap is for. Nothing in the chart parses or formats a date, which is why the same board is correct for quarters, halves, Now / Next / Later and named programme increments, and why no reader ever sees a day that is off by one because their timezone differs from the planner's. The one temporal fact the chart needs — which column is today — is stated by the caller rather than read off a clock that would disagree between the server render and the browser.
- Confidence, not progress — the only quality a bar carries is how firm its date is: committed, likely, or still being explored, drawn as a ramp of border and tint rather than a second palette. A roadmap that starts reporting per-cent complete has quietly become a delivery board, and then it is read every week instead of every quarter.
- Packing is what makes a lane legible — epics inside a lane are laid into the earliest sub-row whose last epic ended in a strictly earlier bucket, so two epics sharing a quarter are always stacked rather than overlapped, and a lane is exactly as tall as the rows it needs. The busiest team is visibly the tallest lane before a single number is read.
- The plan's own contradiction — a coarse roadmap cannot check dates, but it can check load: any bucket where a lane holds more epics than the team said it can carry is marked in place and named in a sentence. That is the difference between a picture of intentions and an artifact that argues back.
- One focus column, one accent — the column headers are a radiogroup, so exactly one column is the headline's subject at any moment. Choosing another moves the single saturated accent and re-reads the epic count, the confidence mix and the carry-in for that column alone — and moves no bar, because the layout never depended on the choice.
- Largest-remainder shares — lane shares partition the plan's epic-buckets into whole per cents that add to exactly 100, weighting a three-quarter epic as three. Rounding each lane on its own is how four lanes print 101 and the whole board reads as a data error.
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.
OKR Tree
A four-state objective tree whose parents are the weighted rollup of their children, with sibling weights that do not sum to 1 either normalised out loud or drawn at the scale they actually describe.