Slope Chart
A four-state slope chart for two points in time — labelled at both ends, with a pooling label-avoidance pass, leader lines that go back to the real endpoint, and a same-unit check that refuses to draw a slope across two dimensions.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-slope.jsonPrompt
Build a React + TypeScript + Tailwind "ChartSlope" card — a Tufte slopegraph
drawn as hand-rolled SVG (recharts has no shape for it) with zod for the
contract and a ResizeObserver for the width.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
caption?: string;
before: { label: string; unit?: string };
after: { label: string; unit?: string };
higherIsBetter?: boolean; // default true
items: { id: string; label: string; before: number; after: number }[] }.
- The unit is modelled PER END, not once for the chart. That is the only way
the mismatch can be refused: a slope across two dimensions is not a change.
The schema rejects it, and the component checks again at render time,
because props are z.infer types and a caller that never parsed its feed is
still a caller.
- The same pass rejects duplicate ids and non-finite values, and refuses
status:"ready" with an empty items array (that is what status:"empty" is
for). zod 4 already rejects NaN and infinities for z.number().
- Component props = z.infer of the schema plus labelWidth (default 132,
clamped 64-320), formatValue, onRetry and className.
- Ship two pure functions beside the schema: resolveLabelPositions() for the
avoidance and buildSlopeLayout() returning per-row delta, relative change,
direction, valence, the two endpoint y values and the two label y values.
Behavior
- TWO PASSES THAT NEVER MIX. Endpoints come from one linear value scale and
nothing else; labels are placed afterwards and are the only thing allowed to
move. That split is what guarantees the two properties a slope chart lives
on: a line's rise in px stays exactly proportional to its change in value
(measured worst deviation 4.3e-14px across a 12-series chart), and two equal
values draw a truly horizontal line (measured y1 = y2 = 380.39568345323744
while that row's label was displaced 17.4px to make room).
- LABEL AVOIDANCE is pool-adjacent-violators — isotonic regression with a
minimum gap. Sort by wanted position, then merge any two neighbours closer
than one label height plus air, recentring the merged block on the mean of
its members' wishes. The obvious alternative, walking down the list pushing
each label below the previous one, only ever moves labels one way: on a
cluster of five near-identical values it drags the whole cluster downward
for 359px of total displacement where pooling costs 215px, and it biases
every crowded chart in one direction. Guarantee two invariants for any
input: no two placed labels are closer than the spacing, and the result
stays inside the band whenever the band can hold it — when it cannot, widen
the band rather than stack text (20,000 randomised cases: smallest gap
36.000000px, zero out-of-bounds, zero order inversions).
- LEADER LINES pay for the split. Every label is joined to its real endpoint
by a line whose far end IS that endpoint's coordinate, so a label pushed
90px away still says which line it belongs to (measured: all 24 leaders on
the crowded chart end exactly on their own dot, deviation 0px). Colour them
with the row's own colour at about 55% opacity, not with a border token:
--border is oklch(1 0 0 / 10%) on the dark card, which makes the longest
leaders invisible exactly when they matter most.
- The band's height grows with the series count ((n-1) * spacing, floor 200px)
so the columns always have room for every label at full spacing. Never cap
it: capping means dropping labels or overlapping them, and both are lies.
- HOVER picks the NEAREST line, computed from the pointer's position in user
units via getScreenCTM().inverse(). Do not widen each line into a
transparent hit stroke: hit strokes of crossing lines overlap, so the
topmost one wins wherever they do and pointing straight at a line can
highlight a different one (measured on the demo data). Non-hovered rows drop
to 25% opacity; labels stay at full opacity, because dimming text is not a
highlight. Where two lines genuinely cross, either answer is correct.
- Four first-class states: a skeleton with two label columns and faint
diagonals (aria-hidden), an empty state, an error state showing either the
transport message or the specific data issue plus a "Try again" button only
when onRetry exists, and ready. status:"ready" with no items renders the
empty state rather than an empty frame.
Rendering & styling
- The svg carries viewBox="0 0 {width} {height}" where {width} is the measured
container width, or a fallback until the ResizeObserver reports. Because the
viewBox always matches the number the geometry was computed for, the
un-measured frame (including the server's render) is drawn scaled to fit
instead of clipped or spilled, and once the real width arrives the scale is
exactly 1 so text is at its stated px size. Clamp each label column against
the container so the plot never drops below 96px.
- Labels live in foreignObject with CSS truncate and pointer-events:none. Let
the browser do the fitting: estimating advance widths in JS is off by -29%
on all-caps runs and +30% on digits. pointer-events:none is not cosmetic —
an HTML box hit-tests its whole rectangle even with no background, so a
label lane would otherwise swallow hovers meant for the plot behind it.
- COLOUR IS NOT THE ONLY CHANNEL, and it encodes VALENCE, not direction. A
falling cost is good news, so higherIsBetter decides which end of the
palette a line gets: good = var(--chart-2) (hue 150), bad = var(--chart-5)
(hue 20), unchanged = var(--muted-foreground). Direction is carried
separately by a triangle glyph drawn as an SVG path in the right-hand label,
so up-or-down survives greyscale and colour-vision deficiency and the two
facts never collapse into one channel. Keep the chart tokens on shapes only:
the text stays text-foreground / text-muted-foreground.
- Accessibility: the plot is role="img" with an aria-label summarising the
series count, how many rose, fell and held, the biggest rise and fall, and
which direction counts as an improvement. role="img" is
children-presentational, which is safe here precisely because nothing inside
is focusable. Below it, an sr-only WRAPPER DIV holds a real table with both
values, the change and the direction in words. 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.
- Respect prefers-reduced-motion: the only animation is the hover opacity
transition, and motion-reduce clears it while the highlight still works.
Customization levers
- labelWidth is the truncation knob. It sets the label column and therefore
how much of a long name survives; the column headings share the same lane,
so raise it for headings like "Before automation". Truncation is always
deliberate, never an accident of layout — the full name is in the sr-only
table either way.
- higherIsBetter belongs to the metric, not to the theme: set it false for
cost, latency, churn, defect count. Swap the two tokens in the valence table
if your palette says otherwise, and lines, dots, leaders and arrows all
follow together.
- formatValue for currency, compact notation or a locale other than en-US. The
default appends the shared unit with no space when the unit is pure
punctuation ("38.4%") and with one otherwise ("12.5 min").
- Density: the label height, the 6px of air between labels and the 200px
minimum band are the three numbers trading vertical size for breathing room.
Drop the value line for a name-only chart, or drop the left-hand names for a
chart read strictly left to right.
- Emphasis: swap the valence colour formula for a highlight set (one series in
colour, the rest in --muted-foreground) when the chart is making one point
rather than reporting a table.
- The hover radius decides how forgiving the highlight is; raise it for touch
surfaces, or drive the same hovered state from an external legend.Concepts
- Two passes, one direction of influence — the value scale decides where the points are, and the label pass may only move labels. Nothing about a label can reach back into the geometry, which is what makes "slope is proportional to change" and "equal values draw a horizontal line" true by construction instead of by luck.
- Pool adjacent violators — the avoidance algorithm. Labels closer together than one label height are merged into a block, and the block is recentred on the mean of its members' wanted positions: the minimum-displacement placement that still preserves order. Pushing each label below the previous one is cheaper to write and biases every crowded chart downward.
- Leader line — the thread from a displaced label back to its real endpoint. Its far end is the endpoint coordinate itself, so a label can move as far as it needs to without ever pointing at the wrong line.
- Valence vs direction — two different facts that must not share one channel. Colour says whether the change is good news, which depends on
higherIsBetter; the triangle glyph says which way the value went. On a cost chart a falling line is green and its arrow still points down. - Same-unit refusal — the unit belongs to each end of the contract so that a mismatch is expressible, and therefore refusable. A slope drawn between dollars and euros looks completely normal and means nothing.
- Nearest-line hover — the highlight is chosen by distance from the pointer to each line, not by whichever transparent hit stroke happens to be on top. With twelve crossing lines those strokes overlap everywhere, and z-order is not what the eye is pointing at.
Bump Chart
A four-state bump chart that plots position instead of value — reversed integer rows, competition ranking for ties, edge labels at both ends, and a broken line wherever an entity is off the board.
Chat Sidebar
A conversation-history sidebar — calendar-day groups cut in the display time zone, in-place rename with rollback, delete confirmed by title, pinning, local search with its own no-match state, and real loading/empty/error branches.