{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"slope-chart","type":"registry:ui","title":"Slope chart","description":"Before and after on two axes: lines draw in, rank moves sit beside each value, and switching datasets slides every line to its new slope.","categories":["data"],"dependencies":["motion"],"registryDependencies":["https://uiarc.dev/r/arc-motion-tokens.json"],"files":[{"path":"registry/components/slope-chart/slope-chart.tsx","type":"registry:component","target":"@components/arc/slope-chart/slope-chart.tsx","content":"\"use client\";\n\nimport { useImperativeHandle, useLayoutEffect, useRef, useState, useSyncExternalStore, type KeyboardEvent, type PointerEvent, type Ref } from \"react\";\nimport { AnimatePresence, motion, useInView, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./slope-chart.module.css\";\n\nexport interface SlopeItem {\n /** Stable identity. An item keeps its line across datasets, so switching morphs it. */\n key: string;\n /** Name beside the start value, in the tooltip, and in the table. */\n label: string;\n /** Value in the first period. */\n start: number;\n /** Value in the second period. */\n end: number;\n}\n\n/** Use a slope chart to show how several items changed between exactly two moments, and how their order changed. */\nexport interface SlopeChartProps {\n data: SlopeItem[];\n /** What is measured, such as \"Conversion rate by channel\". Names the chart for assistive technology. */\n label: string;\n /** Heading of the first column, such as \"Q1\". */\n startLabel: string;\n /** Heading of the second column, such as \"Q2\". */\n endLabel: string;\n formatValue?: (value: number) => string;\n /** Formats the change in the tooltip. Defaults to the difference in formatValue's terms. */\n formatChange?: (change: number, item: SlopeItem) => string;\n /** Plot height in pixels. Defaults to 44 pixels per item. */\n height?: number;\n /** The item drawn in the accent, such as the one the story is about. */\n highlightKey?: string | null;\n /** Controlled item in focus. The others fade while one is in focus. */\n activeKey?: string | null;\n onActiveChange?: (key: string | null) => void;\n /** Rank movement beside each end value. */\n ranks?: boolean;\n emptyLabel?: string;\n ref?: Ref;\n className?: string;\n}\n\ntype Row = { item: SlopeItem; y0: number; y1: number; l0: number; l1: number; rank0: number; rank1: number; order: number };\n\nconst { spring } = motionTokens;\nconst follow = { stiffness: 480, damping: 42, restDelta: .01 };\nconst LABEL_GAP = 20, PAD = 14, HEAD = 28;\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\nconst grouped = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 1 });\n\nconst subscribeNothing = () => () => {};\nfunction useReducedMotionSafe() {\n const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);\n return !!useReducedMotion() && hydrated;\n}\n\n/** Moves labels apart just enough to stop overlaps: touching labels form a group centred on where its members want to be. */\nfunction spread(wanted: number[], gap: number, low: number, high: number): number[] {\n const order = wanted.map((y, index) => ({ y, index })).sort((a, b) => a.y - b.y);\n let groups = order.map(entry => ({ members: [entry], top: entry.y }));\n for (let changed = true; changed;) {\n changed = false;\n const next: typeof groups = [];\n for (const group of groups) {\n const prev = next[next.length - 1];\n if (prev && prev.top + prev.members.length * gap > group.top) {\n const members = [...prev.members, ...group.members];\n next[next.length - 1] = { members, top: members.reduce((sum, member, k) => sum + member.y - k * gap, 0) / members.length };\n changed = true;\n } else next.push(group);\n }\n groups = next.map(group => ({ ...group, top: clamp(group.top, low, Math.max(low, high - (group.members.length - 1) * gap)) }));\n }\n const out = new Array(wanted.length);\n groups.forEach(group => group.members.forEach((member, k) => { out[member.index] = group.top + k * gap; }));\n return out;\n}\n\n/** Rank change arrow: up, down, or level. The number rolls in when it changes. */\nfunction Rank({ move, reduced }: { move: number; reduced: boolean }) {\n const text = move === 0 ? \"=\" : `${Math.abs(move)}`;\n return 0 ? \"up\" : move < 0 ? \"down\" : \"level\"} aria-hidden=\"true\">\n {move !== 0 && 0 ? \"M4 7V1M1.5 3.5 4 1l2.5 2.5\" : \"M4 1v6M1.5 4.5 4 7l2.5-2.5\"} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.3\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />}\n \n \n = 0 ? 6 : -6 }} animate={{ opacity: 1, y: 0 }} exit={reduced ? { opacity: 0 } : { opacity: 0, y: move >= 0 ? -6 : 6 }} transition={reduced ? { duration: 0 } : spring.snappy}>{text}\n \n \n ;\n}\n\nexport function SlopeChart({ data, label, startLabel, endLabel, formatValue, formatChange, height, highlightKey = null, activeKey, onActiveChange, ranks = true, emptyLabel = \"No data\", ref, className }: SlopeChartProps) {\n const reduced = useReducedMotionSafe();\n const figure = useRef(null);\n const stage = useRef(null);\n const tip = useRef(null);\n useImperativeHandle(ref, () => figure.current as HTMLElement);\n const inView = useInView(figure, { once: true, amount: .3 });\n const empty = data.length === 0;\n const plotHeight = height ?? Math.max(200, data.length * 44);\n\n const [width, setWidth] = useState(0);\n useLayoutEffect(() => {\n const node = stage.current;\n if (!node) return;\n const read = () => setWidth(node.clientWidth);\n read();\n if (typeof ResizeObserver === \"undefined\") return;\n const observer = new ResizeObserver(read);\n observer.observe(node);\n return () => observer.disconnect();\n }, []);\n const narrow = width < 440;\n const leftRoom = narrow ? (width < 300 ? 116 : 132) : 168, rightRoom = ranks ? (narrow ? 76 : 96) : (narrow ? 52 : 64);\n const x0 = leftRoom, x1 = Math.max(x0 + 40, width - rightRoom);\n\n // One shared value axis for both columns, padded so dots never touch the edges.\n const values = data.flatMap(item => [item.start, item.end]);\n let lo = Math.min(...values), hi = Math.max(...values);\n if (!Number.isFinite(lo)) { lo = 0; hi = 1; }\n if (hi - lo < 1e-9) { lo -= 1; hi += 1; }\n const yOf = (value: number) => PAD + (1 - (value - lo) / (hi - lo)) * (plotHeight - PAD * 2);\n const rankOf = (field: \"start\" | \"end\") => { const sorted = [...data].sort((a, b) => b[field] - a[field]); return (item: SlopeItem) => sorted.indexOf(item) + 1; };\n const r0 = rankOf(\"start\"), r1 = rankOf(\"end\");\n const l0 = spread(data.map(item => yOf(item.start)), LABEL_GAP, 8, plotHeight - 8);\n const l1 = spread(data.map(item => yOf(item.end)), LABEL_GAP, 8, plotHeight - 8);\n const rows: Row[] = data.map((item, i) => ({ item, y0: yOf(item.start), y1: yOf(item.end), l0: l0[i]!, l1: l1[i]!, rank0: r0(item), rank1: r1(item), order: 0 }));\n const byEnd = [...rows].sort((a, b) => a.rank1 - b.rank1);\n byEnd.forEach((row, k) => { row.order = k; });\n\n const [ownActive, setOwnActive] = useState(null);\n const wanted = activeKey !== undefined ? activeKey : ownActive;\n const active = wanted && data.some(item => item.key === wanted) ? wanted : null;\n const setActive = (key: string | null) => { if (activeKey === undefined) setOwnActive(key); if (key !== active) onActiveChange?.(key); };\n\n // The tooltip glides to the middle of the line in focus and stays inside the chart.\n const tipX = useMotionValue(0), tipY = useMotionValue(0);\n const tipSpringX = useSpring(tipX, follow), tipSpringY = useSpring(tipY, follow);\n const wasShown = useRef(false);\n const activeRow = rows.find(row => row.item.key === active) ?? null;\n // The tooltip keeps the last item's content while it fades out, so it never collapses to an empty box mid-fade.\n const [shownKey, setShownKey] = useState(null);\n if (active && active !== shownKey) setShownKey(active);\n const tipRow = activeRow ?? rows.find(row => row.item.key === shownKey) ?? null;\n useLayoutEffect(() => {\n const bubble = tip.current;\n if (!activeRow || !bubble || !width) { wasShown.current = false; return; }\n const tw = bubble.offsetWidth, th = bubble.offsetHeight;\n const mid = (activeRow.y0 + activeRow.y1) / 2, cx = (x0 + x1) / 2;\n const above = mid - th - 16 >= 0;\n const left = clamp(cx - tw / 2, 0, Math.max(0, width - tw));\n const top = clamp(HEAD + (above ? mid - th - 16 : mid + 16), 0, Math.max(0, plotHeight + HEAD - th));\n tipX.set(left); tipY.set(top);\n if (!wasShown.current || reduced) { tipSpringX.jump(left); tipSpringY.jump(top); }\n wasShown.current = true;\n });\n\n // The line nearest the pointer, measured at the pointer's position along the plot.\n const nearest = (clientX: number, clientY: number) => {\n const rect = stage.current?.getBoundingClientRect();\n if (!rect || empty) return null;\n const x = clamp(clientX - rect.left, x0, x1), y = clientY - rect.top - HEAD;\n const t = (x - x0) / Math.max(1, x1 - x0);\n let best: Row | null = null, distance = Infinity;\n for (const row of rows) {\n const inLine = Math.abs(row.y0 + (row.y1 - row.y0) * t - y);\n const onLabel = clientX - rect.left < x0 ? Math.abs(row.l0 - y) : clientX - rect.left > x1 ? Math.abs(row.l1 - y) : Infinity;\n const d = Math.min(inLine, onLabel);\n if (d < distance) { distance = d; best = row; }\n }\n return distance <= 22 ? best?.item.key ?? null : null;\n };\n const onPointerMove = (event: PointerEvent) => { if (event.pointerType === \"mouse\" || event.buttons) setActive(nearest(event.clientX, event.clientY)); };\n const onKeyDown = (event: KeyboardEvent) => {\n if (empty) return;\n if (event.key === \"Escape\" && active) { event.preventDefault(); setActive(null); return; }\n const at = activeRow ? activeRow.order : -1;\n const moves: Record = { ArrowDown: at + 1, ArrowRight: at + 1, ArrowUp: at < 0 ? 0 : at - 1, ArrowLeft: at < 0 ? 0 : at - 1, Home: 0, End: byEnd.length - 1 };\n if (!(event.key in moves)) return;\n event.preventDefault();\n setActive(byEnd[clamp(moves[event.key]!, 0, byEnd.length - 1)]!.item.key);\n };\n\n const format = (value: number) => formatValue ? formatValue(value) : grouped.format(value);\n const change = (item: SlopeItem) => formatChange ? formatChange(item.end - item.start, item) : `${item.end >= item.start ? \"+\" : \"−\"}${format(Math.abs(item.end - item.start))}`;\n const rankText = (row: Row) => row.rank0 === row.rank1 ? \"same rank\" : row.rank1 < row.rank0 ? `up ${row.rank0 - row.rank1} to rank ${row.rank1}` : `down ${row.rank1 - row.rank0} to rank ${row.rank1}`;\n const announce = activeRow ? `${activeRow.item.label}, ${startLabel} ${format(activeRow.item.start)}, ${endLabel} ${format(activeRow.item.end)}, ${change(activeRow.item)}, ${rankText(activeRow)}` : \"\";\n const summary = empty ? `${label}. ${emptyLabel}.` : `${label}, ${startLabel} to ${endLabel}. ${byEnd.map(row => `${row.item.label} ${format(row.item.start)} to ${format(row.item.end)}`).join(\", \")}.`;\n const settle = reduced ? { duration: 0 } : spring.morph;\n const drawn = inView || reduced;\n\n return
\n
{ if (event.pointerType !== \"mouse\") { event.currentTarget.setPointerCapture?.(event.pointerId); setActive(nearest(event.clientX, event.clientY)); } }}\n onPointerUp={event => { if (event.pointerType !== \"mouse\") setActive(null); }} onPointerCancel={() => setActive(null)} onPointerLeave={event => { if (event.pointerType === \"mouse\") setActive(null); }}\n onKeyDown={onKeyDown} onBlur={() => setActive(null)} onFocus={event => { if (event.currentTarget.matches(\":focus-visible\") && !empty && !active) setActive(byEnd[0]!.item.key); }}>\n {width > 0 && <>\n
\n {startLabel}\n {endLabel}\n
\n
\n \n \n \n {rows.map((row, i) => {\n const key = row.item.key, on = active === key, accent = highlightKey === key;\n const delay = reduced ? 0 : i * .05;\n return \n \n \n \n \n \n ;\n })}\n \n {rows.map((row, i) => {\n const key = row.item.key, on = active === key, accent = highlightKey === key;\n const delay = reduced ? 0 : i * .05;\n return
\n \n {row.item.label}\n {format(row.item.start)}\n \n \n {format(row.item.end)}\n {ranks && }\n \n
;\n })}\n
\n }\n \n

{tipRow?.item.label ?? \"\"}

\n

{startLabel}{tipRow ? format(tipRow.item.start) : \"\"}

\n

{endLabel}{tipRow ? format(tipRow.item.end) : \"\"}

\n

{tipRow ? `${change(tipRow.item)}, ${rankText(tipRow)}` : \"\"}

\n
\n {empty &&

{emptyLabel}

}\n
\n

{announce}

\n

{summary}

\n {!empty &&
\n \n \n {byEnd.map(row => )}\n
{label}
Item{startLabel}{endLabel}ChangeRank
{row.item.label}{format(row.item.start)}{format(row.item.end)}{change(row.item)}{row.rank0} to {row.rank1}
}\n
;\n}\n\nexport default SlopeChart;\n"},{"path":"registry/components/slope-chart/slope-chart.module.css","type":"registry:component","target":"@components/arc/slope-chart/slope-chart.module.css","content":".figure { position: relative; display: grid; min-width: 0; margin: 0; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n\n/* Series colors (--series-1 to --series-4) come from registry/foundation.css. */\n\n.stage { position: relative; min-width: 0; outline: none; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }\n.columns { position: relative; height: 28px; }\n.column { position: absolute; top: 0; color: var(--text-muted); font-size: var(--text-sm); line-height: 20px; white-space: nowrap; }\n\n.plot { position: relative; }\n.svg { display: block; overflow: visible; }\n.axis { stroke: var(--border); stroke-width: 1; shape-rendering: crispEdges; }\n\n/* Every line is one neutral ink; the story item takes the first series (derived from the accent) and the item in focus comes forward. */\n.item { --ink: color-mix(in oklab, var(--foreground) 36%, var(--surface)); transition: opacity var(--duration-standard) var(--ease-standard); }\n.item[data-accent] { --ink: var(--series-1); }\n.item[data-on]:not([data-accent]) { --ink: var(--foreground); }\n.item[data-dim] { opacity: .14; }\n.line { stroke: var(--ink); stroke-width: 2; stroke-linecap: round; fill: none; transition: stroke var(--duration-fast) var(--ease-standard); }\n.dot { fill: var(--ink); stroke: var(--surface); stroke-width: 2; transform-box: fill-box; transform-origin: center; transition: fill var(--duration-fast) var(--ease-standard); }\n.leader { fill: none; stroke: var(--border-strong); stroke-width: 1; }\n\n.labels { transition: opacity var(--duration-standard) var(--ease-standard); }\n.labels[data-dim] { opacity: .3; }\n.start, .end { position: absolute; top: 0; display: flex; height: 20px; align-items: center; gap: 8px; margin-top: -10px; font-size: var(--text-sm); line-height: 20px; white-space: nowrap; pointer-events: none; }\n.start { justify-content: flex-end; max-width: 100%; }\n/* On a phone the labels step down a size so the longest names fit beside the first column instead of running off the edge. */\n.stage[data-narrow] .start, .stage[data-narrow] .end { gap: 6px; font-size: var(--text-xs); }\n.name { min-width: 0; overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; }\n.value { color: var(--foreground); font-variant-numeric: tabular-nums; }\n.labels[data-on] .name, .labels[data-accent] .name { color: var(--foreground); }\n/* Only the story item is medium; the item in focus changes color, not weight, so its label never shifts sideways. */\n.labels[data-accent] .value { font-weight: 500; }\n\n.rank { display: inline-flex; min-width: 30px; align-items: center; gap: 2px; color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }\n.rank[data-move=\"up\"] { color: var(--text-secondary); }\n.rankSwap { position: relative; display: inline-flex; overflow: hidden; }\n\n.tooltip { position: absolute; top: 0; left: 0; z-index: 2; display: grid; min-width: 168px; max-width: min(240px, 100%); gap: 3px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-raised); box-shadow: var(--shadow-floating); opacity: 0; pointer-events: none; scale: .96; transition: opacity var(--duration-fast) var(--ease-standard), scale var(--duration-fast) var(--ease-standard); }\n.stage[data-active] .tooltip { opacity: 1; scale: 1; }\n.tipTitle { margin: 0 0 2px; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); white-space: nowrap; }\n.tipRow { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0; font-size: var(--text-sm); line-height: 1.3; }\n.tipName { color: var(--text-secondary); }\n.tipValue { color: var(--foreground); font-weight: 500; font-variant-numeric: tabular-nums; }\n.tipMeta { margin: 2px 0 0; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); font-variant-numeric: tabular-nums; }\n\n.message { display: grid; min-height: 160px; place-items: center; margin: 0; color: var(--text-muted); font-size: var(--text-sm); }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (prefers-reduced-motion: reduce) {\n .item, .line, .dot, .labels, .tooltip { transition: none; }\n}\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/slope-chart","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/slope-chart","markdown":"https://uiarc.dev/components/slope-chart/markdown","tags":["data","chart","new"]}}