{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/slope-chart/slope-chart.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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<HTMLElement>;\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<number>(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 <span className={styles.rank} data-move={move > 0 ? \"up\" : move < 0 ? \"down\" : \"level\"} aria-hidden=\"true\">\n    {move !== 0 && <svg width=\"8\" height=\"8\" viewBox=\"0 0 8 8\"><path d={move > 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\" /></svg>}\n    <span className={styles.rankSwap}>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        <motion.span key={text} initial={reduced ? { opacity: 0 } : { opacity: 0, y: move >= 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}</motion.span>\n      </AnimatePresence>\n    </span>\n  </span>;\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<HTMLElement>(null);\n  const stage = useRef<HTMLDivElement>(null);\n  const tip = useRef<HTMLDivElement>(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<string | null>(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<string | null>(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<HTMLDivElement>) => { if (event.pointerType === \"mouse\" || event.buttons) setActive(nearest(event.clientX, event.clientY)); };\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\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<string, number> = { 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 <figure ref={figure} className={[styles.figure, className].filter(Boolean).join(\" \")} aria-label={label}>\n    <div ref={stage} className={styles.stage} data-narrow={narrow || undefined} data-active={active ? true : undefined}\n      role=\"group\" tabIndex={empty ? -1 : 0} aria-roledescription=\"slope chart\" aria-label={`${label}. Use up and down arrows to move between items in ${endLabel} order.`}\n      onPointerMove={onPointerMove} onPointerDown={event => { 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        <div className={styles.columns} aria-hidden=\"true\">\n          <span className={styles.column} style={{ left: x0, translate: \"-50% 0\" }}>{startLabel}</span>\n          <span className={styles.column} style={{ left: x1, translate: \"-50% 0\" }}>{endLabel}</span>\n        </div>\n        <div className={styles.plot} style={{ height: plotHeight }}>\n          <svg className={styles.svg} width={width} height={plotHeight} aria-hidden=\"true\" focusable=\"false\">\n            <line className={styles.axis} x1={x0} x2={x0} y1={0} y2={plotHeight} />\n            <line className={styles.axis} x1={x1} x2={x1} y1={0} y2={plotHeight} />\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 <g key={key} className={styles.item} data-on={on || undefined} data-accent={accent || undefined} data-dim={(active !== null && !on) || undefined}>\n                <motion.path className={styles.leader} initial={false} animate={{ d: `M${x0 - 6},${row.l0}L${x0 - 1},${row.y0}` }} transition={settle} />\n                <motion.path className={styles.leader} initial={false} animate={{ d: `M${x1 + 6},${row.l1}L${x1 + 1},${row.y1}` }} transition={settle} />\n                <motion.line className={styles.line} x1={x0} x2={x1} initial={reduced ? false : { y1: row.y0, y2: row.y1, pathLength: 0 }} animate={{ y1: row.y0, y2: row.y1, pathLength: drawn ? 1 : 0 }}\n                  transition={{ y1: settle, y2: settle, pathLength: reduced ? { duration: 0 } : { duration: .7, ease: motionTokens.ease.inOut, delay: .1 + delay } }} />\n                <motion.circle className={styles.dot} cx={x0} r={4} initial={reduced ? false : { cy: row.y0, scale: 0 }} animate={{ cy: row.y0, scale: drawn ? 1 : 0 }} transition={{ cy: settle, scale: reduced ? { duration: 0 } : { ...spring.snappy, delay } }} />\n                <motion.circle className={styles.dot} cx={x1} r={4} initial={reduced ? false : { cy: row.y1, scale: 0 }} animate={{ cy: row.y1, scale: drawn ? 1 : 0 }} transition={{ cy: settle, scale: reduced ? { duration: 0 } : { ...spring.snappy, delay: .7 + delay } }} />\n              </g>;\n            })}\n          </svg>\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 <div key={key} className={styles.labels} data-on={on || undefined} data-accent={accent || undefined} data-dim={(active !== null && !on) || undefined} aria-hidden=\"true\">\n              <motion.span className={styles.start} style={{ right: width - x0 + 10, maxWidth: x0 - 10 }} initial={reduced ? false : { y: row.l0, opacity: 0 }} animate={{ y: row.l0, opacity: drawn ? 1 : 0 }} transition={{ y: settle, opacity: { duration: .3, delay } }}>\n                <span className={styles.name}>{row.item.label}</span>\n                <span className={styles.value}>{format(row.item.start)}</span>\n              </motion.span>\n              <motion.span className={styles.end} style={{ left: x1 + 10 }} initial={reduced ? false : { y: row.l1, opacity: 0 }} animate={{ y: row.l1, opacity: drawn ? 1 : 0 }} transition={{ y: settle, opacity: { duration: .3, delay: .7 + delay } }}>\n                <span className={styles.value}>{format(row.item.end)}</span>\n                {ranks && <Rank move={row.rank0 - row.rank1} reduced={reduced} />}\n              </motion.span>\n            </div>;\n          })}\n        </div>\n      </>}\n      <motion.div ref={tip} className={styles.tooltip} style={{ x: reduced ? tipX : tipSpringX, y: reduced ? tipY : tipSpringY }} aria-hidden=\"true\">\n        <p className={styles.tipTitle}>{tipRow?.item.label ?? \"\"}</p>\n        <p className={styles.tipRow}><span className={styles.tipName}>{startLabel}</span><span className={styles.tipValue}>{tipRow ? format(tipRow.item.start) : \"\"}</span></p>\n        <p className={styles.tipRow}><span className={styles.tipName}>{endLabel}</span><span className={styles.tipValue}>{tipRow ? format(tipRow.item.end) : \"\"}</span></p>\n        <p className={styles.tipMeta}>{tipRow ? `${change(tipRow.item)}, ${rankText(tipRow)}` : \"\"}</p>\n      </motion.div>\n      {empty && <p className={styles.message}>{emptyLabel}</p>}\n    </div>\n    <p className={styles.srOnly} aria-live=\"polite\" aria-atomic=\"true\">{announce}</p>\n    <p className={styles.srOnly}>{summary}</p>\n    {!empty && <div className={styles.srOnly}><table>\n      <caption>{label}</caption>\n      <thead><tr><th scope=\"col\">Item</th><th scope=\"col\">{startLabel}</th><th scope=\"col\">{endLabel}</th><th scope=\"col\">Change</th><th scope=\"col\">Rank</th></tr></thead>\n      <tbody>{byEnd.map(row => <tr key={row.item.key}><th scope=\"row\">{row.item.label}</th><td>{format(row.item.start)}</td><td>{format(row.item.end)}</td><td>{change(row.item)}</td><td>{row.rank0} to {row.rank1}</td></tr>)}</tbody>\n    </table></div>}\n  </figure>;\n}\n\nexport default SlopeChart;\n"
    },
    {
      "path": "registry/components/slope-chart/slope-chart.module.css",
      "type": "registry:component",
      "target": "@components/xzero/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://ui.xzero.co.uk/components/slope-chart",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "chart",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/slope-chart",
    "markdown": "https://ui.xzero.co.uk/components/slope-chart/markdown"
  }
}