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…

"use client"

import * as React from "react"
import { CartesianGrid, Line, LineChart, ReferenceDot, XAxis, YAxis } from "recharts"

import {
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
} from "@/components/ui/chart"
import { cn } from "@/lib/utils"
import type {
  ChartStepLineData,

Installation

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

Prompt

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 samplingafter says the reading holds until the next one, before says it already applied since the previous one, middle says 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 scalelevels maps 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 to sr-only instead 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.

On This Page