Charts
Step Line Chart
A four-state step chart for discrete state over time — the value holds flat and then jumps, with before/middle/after alignment, change markers and a change table.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-step-line.jsonPrompt
Build a React + TypeScript + Tailwind "ChartStepLine" card on the shadcn chart
primitives (ChartContainer / ChartTooltip over recharts 3) with zod.
When a step chart is the honest chart (put this in the component's docs too)
- Use steps when the series is piecewise constant: the value stays put until
something changes it, then jumps. Plan tier, config version, feature-flag
state, replica count, price band, stock on hand, alert threshold.
- A straight line between two samples asserts the value passed through every
number in between. For an account that went Starter -> Enterprise it draws a
moment of "Pro and a half" that never existed; for a replica count it draws
3.4 replicas. Steps assert only what was recorded: this value, until the
next one.
- Use a plain line chart instead when the quantity really is continuous and was
merely sampled (temperature, latency, revenue per minute) — there the
interpolation is a reasonable guess, and steps would fake a sharp edge.
- Rule of thumb: name the intermediate values out loud. If they are nonsense,
the chart must be stepped.
Contract
- One zod schema is the single source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
unit?: string;
points: { date: ISO string; value: number; note?: string }[];
levels?: { value: number; label: string }[] (min 2) }.
- Refinements that make a bad feed fail loudly instead of drawing a lie:
ready needs >= 1 point; timestamps strictly increasing (a step chart holds
each value forward, so an unsorted or duplicated stamp reverses history);
when levels are given, every point value must exist in levels.
- Component props = z.infer of the schema plus align ("after" | "before" |
"middle", default "after"), markChanges (default true), showChangeTable
(default true), onRetry?, className. No hand-written parallel interface.
Behavior
- align is the claim you are making about the samples, not a style knob:
- "after" -> the sampled value holds until the next sample and jumps there.
Event logs, plan changes, config pushes. (recharts stepAfter)
- "before" -> the value already applied since the previous sample, so the
jump sits at the previous timestamp. Period-end readings.
(stepBefore)
- "middle" -> the change happened somewhere between two samples and the exact
moment is unknown, so the jump is drawn halfway. Polled
snapshots. (step)
An unknown value falls back to "after".
- Change points = the first sample plus every sample whose value differs from
the one before. They drive three things at once: a dot on the chart
(markChanges), the rows of the change table, and the "N changes" count.
Flat runs get no marker, so the markers read as "here is where it moved".
- levels turns the y axis ordinal: ticks are exactly the level values and the
labels replace the numbers ("2" -> "Pro") on the axis, in the tooltip, in the
header pill and in the table. Without levels the axis stays numeric and `unit`
is appended in the tooltip and the table instead ("4 replicas").
- Tooltip: full timestamp as the label, then colour chip + title + the level
name, and the point's note underneath when it has one.
- Four first-class branches in one bg-card panel: loading (header skeleton +
pulsing staircase silhouette), empty (dashed staircase + "No changes
recorded"), error (message + a Try again button only when onRetry exists),
ready (header pill with the current level + chart + change table). A "ready"
payload with zero points renders the empty branch rather than a blank canvas.
- Change table is a real <table> with scope="col" headers: When / Change / Why,
each row "Free -> Starter" (the arrow is aria-hidden with an sr-only "to").
Its caption restates the alignment ("each value holds until the next row" /
"already applied at the row above" / "until midway to the next row"), so the
words and the curve never contradict each other.
Rendering & styling
- Semantic tokens only: bg-card, border, text-muted-foreground, ring; the
series colour is var(--chart-2). On a monochrome default ramp that is the one
entry legible on both themes (measured 4.74:1 on the light card, 4.18:1 on
the dark one; var(--chart-1) is 1.48:1 on white, var(--chart-5) is 1.31:1 on
the dark card). One constant feeds the stroke, the markers, the header chip
and the tooltip chip.
- Accessibility: role="img" + an aria-label summarising the series ("12 samples
from Jan 1 to Dec 1, 4 changes. Currently Enterprise."), and the change table
always in the DOM — visible by default, sr-only when showChangeTable is
false. Pass accessibilityLayer={false}: recharts 3 enables it by default and
puts a focusable role="application" on the <svg>, which inside a role="img"
(children-presentational) container is a keyboard stop with no name.
- Axis ticks need an explicit tick={{ fill: "var(--muted-foreground)" }};
recharts 3 no longer nests tick text under .recharts-cartesian-axis-tick, so
the shadcn wrapper's fill hook misses it and labels fall back to #666
(3.45:1 on the dark card).
- Give the YAxis a computed literal width (about 6.6px per character of the
longest label, clamped 36–132, labels over 18 chars truncated with an
ellipsis) instead of width="auto": auto re-measures in a second pass and
ReferenceDot keeps the stale offset, which slides every change marker off the
step corners by up to 10px.
- Generate the y ticks yourself in the numeric case (even whole-number steps
from the data range, never padded below zero for a count): recharts keeps the
domain maximum as an extra tick, which prints "9" hard under "10", and a
naive symmetric pad offers "-2 replicas".
- Line: type stepAfter/stepBefore/step, strokeWidth 2, dot={false} (markers are
ReferenceDots so flat samples stay unmarked), isAnimationActive driven by a
prefers-reduced-motion subscription. CartesianGrid horizontal only; XAxis
minTickGap ~28 so 375px thins the ticks instead of overlapping them; dates
formatted with an explicit "en-US" Intl formatter, and date-only ISO strings
parsed as local midnight so ticks never shift a day.
- cn() merges className. sr-only is applied alone, never alongside padding
utilities: tailwind-merge cannot know sr-only zeroes padding, so px-6 py-4
would survive and leave a visible empty box.
Customization levers
- Alignment: expose `align` to the consumer, or hard-code the one your feed
means and drop the other two curves.
- Ordinal vs numeric: pass `levels` for named states; drop it and pass `unit`
for a real count. Level order is taken from the values, so insert a new tier
by giving it a value between two existing ones.
- Change table: keep it visible for audit-style surfaces, set showChangeTable
false in a dense dashboard grid — the rows stay in the accessibility tree.
markChanges=false gives a bare staircase for sparkline-sized cards.
- Density: h-[240px] and px-6 suit a dashboard card; h-[160px] with the table
hidden makes a compact strip.
- Palette: one constant maps the series to var(--chart-N); swap it for a brand
token, or branch per level (e.g. downgrade rows in var(--chart-4)) if your
palette has enough contrast on both themes — check any replacement against
the 3:1 non-text minimum in both.
- Granularity: points are timestamp-keyed, so hourly feeds work as-is; include
a "T" in the ISO strings and the tick/tooltip formatters switch to a
time-of-day format.
- Keyboard tooltips: if you would rather have recharts' arrow-key tooltip
navigation, drop role="img" and the aria-label and re-enable
accessibilityLayer — keep the change table either way.Concepts
- Piecewise-constant honesty — the whole point of the shape: between two samples the value did not travel, it waited. Interpolating a diagonal invents readings ("Pro and a half", "3.4 replicas") that were never in the data, so the chart type is a truth claim, not a style.
- Step alignment as a claim about sampling —
aftersays the reading holds until the next one,beforesays it already applied since the previous one,middlesays the change happened somewhere in between and nobody knows when. Picking one is picking what your feed actually recorded. - Change-point extraction — one derived list (first sample + every sample that differs from its predecessor) feeds the markers, the table rows and the change count, so the dots on the chart and the rows underneath can never disagree.
- Ordinal level scale —
levelsmaps numbers to names and pins the y ticks to exactly those values, turning a magnitude axis into a list of states; the same lookup renders the tooltip, the header pill and the table, so the numbers never leak into the UI. - Summary label plus data table — the chart is a
role="img"with a one-sentence summary, and the change table is the non-visual reading of the same data; hiding it visually (showChangeTable={false}) moves it tosr-onlyinstead of removing it. - Caption follows the curve — the table caption is written from the active alignment, so the sentence under the chart always describes the interval semantics the line is drawing.
100% Stacked Bar Chart
A four-state 100% stacked bar chart whose segments are apportioned with largest-remainder rounding, so every bar's percentages add up to exactly 100.
Range Area Chart
A four-state range-area card: a center line inside one or two nested uncertainty bands, with contradictory bounds repaired and disclosed instead of drawn as crossed polygons.