Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { useResizeObserver } from "@/registry/hooks/use-resize-observer"
import {
  buildSlopeLayout,
  inspectSlopeData,
  type ChartSlopeData,
  type SlopeDirection,
  type SlopeRow,
  type SlopeValence,
} from "./chart-slope.contract"

Installation

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

Prompt

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.

On This Page