{
  "$schema": "https://mian.ro/schema/v2/registry.json",
  "schemaVersion": "2.0.0",
  "generatedAt": "2026-10-02T13:14:06.957Z",
  "id": "kpi",
  "name": "KPI card",
  "category": "Data display",
  "description": "One number that matters, with its change, a sparkline you can hover and a line of context.",
  "tags": [
    "kpi",
    "metric card",
    "sparkline",
    "dashboard card",
    "trend",
    "analytics",
    "number card",
    "stat with chart"
  ],
  "requiresJs": true,
  "license": "LicenseRef-MIAN-1.0",
  "composition": {
    "role": null,
    "intents": [
      "kpi card",
      "metric with sparkline",
      "number with trend",
      "dashboard headline numbers",
      "revenue card"
    ],
    "prominence": null,
    "density": null,
    "worksAfter": [],
    "worksBefore": [],
    "alternatives": [
      "stat",
      "figures"
    ],
    "avoid": null
  },
  "url": "https://mian.ro/components/kpi",
  "markdownUrl": "https://mian.ro/ai/components/kpi.md",
  "classes": [
    {
      "name": "mn-kpis",
      "description": "A responsive row of KPI cards."
    },
    {
      "name": "mn-kpi",
      "description": "One card. Add mn-card for the noodle’s surface."
    },
    {
      "name": "mn-kpi-head",
      "description": "Label row; may end with an icon or a menu."
    },
    {
      "name": "mn-kpi-row",
      "description": "The value and the sparkline, side by side."
    },
    {
      "name": "mn-kpi-value",
      "description": "The number. Add data-mn-count to count up once when first seen."
    },
    {
      "name": "mn-kpi-foot",
      "description": "Change and context: a mn-stat-delta and a few words."
    },
    {
      "name": "mn-spark",
      "description": "On an svg.mn-chart-line: a sparkline without grid or points. mn-up / mn-down colour it; hover, touch and arrow keys show each value. data-labels and data-unit work as on charts."
    }
  ],
  "accessibility": [
    "Say what the change is compared with (“vs last month”); an arrow alone is not information.",
    "The sparkline is role=\"img\" with an aria-label; its values are reachable with the arrow keys.",
    "Up is not always good: colour the delta by meaning (churn going up is mn-down).",
    "Counting up never replaces the text: the final value stays in the DOM."
  ],
  "examples": [
    {
      "title": "KPI row",
      "description": null,
      "html": "<div class=\"mn-kpis\">\n  <article class=\"mn-card mn-kpi\">\n    <div class=\"mn-kpi-head\"><span>Monthly recurring revenue</span></div>\n    <div class=\"mn-kpi-row\"><span class=\"mn-kpi-value\" data-mn-count>€48,210</span><svg class=\"mn-chart-line mn-spark mn-up\" data-values=\"31,33,36,35,39,42,44,48\" data-labels=\"W1,W2,W3,W4,W5,W6,W7,W8\" data-unit=\" k€\" role=\"img\" aria-label=\"Monthly recurring revenue, last eight weeks\"></svg></div>\n    <p class=\"mn-kpi-foot\"><span class=\"mn-stat-delta mn-up\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12 19V5M6 11l6-6 6 6\"/></svg> 6.4%<span class=\"mn-sr-only\"> increase</span></span>vs last month</p>\n  </article>\n  <article class=\"mn-card mn-kpi\">\n    <div class=\"mn-kpi-head\"><span>Active customers</span></div>\n    <div class=\"mn-kpi-row\"><span class=\"mn-kpi-value\" data-mn-count>1,284</span><svg class=\"mn-chart-line mn-spark\" data-values=\"1180,1195,1201,1220,1236,1249,1262,1284\" data-labels=\"W1,W2,W3,W4,W5,W6,W7,W8\" role=\"img\" aria-label=\"Active customers, last eight weeks\"></svg></div>\n    <p class=\"mn-kpi-foot\"><span class=\"mn-stat-delta mn-up\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12 19V5M6 11l6-6 6 6\"/></svg> 38<span class=\"mn-sr-only\"> more</span></span>new this month</p>\n  </article>\n  <article class=\"mn-card mn-kpi\">\n    <div class=\"mn-kpi-head\"><span>Churn</span></div>\n    <div class=\"mn-kpi-row\"><span class=\"mn-kpi-value\" data-mn-count>2.1%</span><svg class=\"mn-chart-line mn-spark mn-down\" data-values=\"1.6,1.7,1.6,1.8,1.8,1.9,2.0,2.1\" data-labels=\"W1,W2,W3,W4,W5,W6,W7,W8\" data-unit=\"%\" role=\"img\" aria-label=\"Churn, last eight weeks\"></svg></div>\n    <p class=\"mn-kpi-foot\"><span class=\"mn-stat-delta mn-down\"><svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M12 5v14M6 13l6 6 6-6\"/></svg> 0.3 pts<span class=\"mn-sr-only\"> higher, which is worse</span></span>vs last month</p>\n  </article>\n</div>"
    }
  ],
  "css": ".mn-kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 13.5rem), 1fr)); gap: var(--mn-gap, 1rem); }\n.mn-kpi { display: grid; align-content: start; gap: .4rem; min-width: 0; padding: 1.05rem 1.2rem 1.1rem; }\n.mn-kpi-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; color: var(--mn-muted); font-size: var(--mn-text-sm); }\n.mn-kpi-head svg { width: 1rem; height: 1rem; }\n.mn-kpi-row { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; min-width: 0; }\n.mn-kpi-value { font: 700 1.75rem/1.1 var(--mn-font); letter-spacing: -.02em; font-variant-numeric: tabular-nums; color: var(--mn-fg); }\n.mn-kpi-foot { margin: 0; color: var(--mn-muted); font-size: .75rem; line-height: 1.4; }\n.mn-kpi-foot .mn-stat-delta { margin-inline-end: .3rem; }\nsvg.mn-spark { --h: 2.5rem; flex: 0 1 7rem; width: 7rem; min-width: 3.5rem; cursor: default; }\n.mn-spark .mn-grid-line { display: none; }\n.mn-spark .mn-line { stroke-width: 1.5px; }\n.mn-spark.mn-up .mn-line, .mn-spark.mn-up .mn-point { stroke: var(--mn-success-text, var(--mn-success)); }\n.mn-spark.mn-up .mn-area { fill: var(--mn-success); }\n.mn-spark.mn-down .mn-line, .mn-spark.mn-down .mn-point { stroke: var(--mn-danger-text, var(--mn-danger)); }\n.mn-spark.mn-down .mn-area { fill: var(--mn-danger); }",
  "js": "/* <svg class=\"mn-chart-line\" data-values=\"3,5,4,8\" data-values-2=\"2,3,6,5\">\n   Draws gridlines, a smoothed line, an area and points. Re-draws if the\n   data attributes change. */\nconst smooth = (pts) => {\n  if (pts.length < 2) return '';\n  let d = `M ${pts[0][0]} ${pts[0][1]}`;\n  for (let i = 0; i < pts.length - 1; i++) {\n    const p0 = pts[i - 1] || pts[i], p1 = pts[i], p2 = pts[i + 1], p3 = pts[i + 2] || p2;\n    // Clamp control points to the segment's own range so the curve never\n    // overshoots the data (a count must not appear to dip below zero).\n    const lo = Math.min(p1[1], p2[1]), hi = Math.max(p1[1], p2[1]);\n    const cl = (v) => Math.max(lo, Math.min(hi, v));\n    const c1 = [p1[0] + (p2[0] - p0[0]) / 6, cl(p1[1] + (p2[1] - p0[1]) / 6)];\n    const c2 = [p2[0] - (p3[0] - p1[0]) / 6, cl(p2[1] - (p3[1] - p1[1]) / 6)];\n    d += ` C ${c1[0].toFixed(1)} ${c1[1].toFixed(1)} ${c2[0].toFixed(1)} ${c2[1].toFixed(1)} ${p2[0]} ${p2[1]}`;\n  }\n  return d;\n};\nfunction drawLine(svg) {\n  // Draw in real pixels so points stay round and strokes stay crisp.\n  const box = svg.getBoundingClientRect();\n  const W = Math.max(60, Math.round(box.width) || 300), H = Math.max(40, Math.round(box.height) || 120), pad = 6;\n  // data-values-2 is not camel-cased by dataset (a digit follows the hyphen): read the attribute.\n  const series = [svg.getAttribute('data-values'), svg.getAttribute('data-values-2')].filter(Boolean).map((s) => s.split(',').map(Number));\n  if (!series.length) return;\n  // A sparkline shows the shape of a change, so it is scaled to its own range, not from zero.\n  const flat = series.flat(), lo = Math.min(...flat), hi = Math.max(...flat), spark = svg.classList.contains('mn-spark');\n  const max = spark ? hi + ((hi - lo) * .12 || 1) : hi * 1.1 || 1;\n  const min = spark ? lo - (hi - lo) * .2 : Math.min(0, lo);\n  svg.setAttribute('viewBox', `0 0 ${W} ${H}`);\n  let out = '';\n  for (let g = 1; g <= 3; g++) out += `<line class=\"mn-grid-line\" x1=\"0\" x2=\"${W}\" y1=\"${(H / 4) * g}\" y2=\"${(H / 4) * g}\"/>`;\n  const all = [];\n  series.forEach((vals, si) => {\n    const cls = si ? ' mn-s2' : '';\n    const pts = vals.map((v, i) => [+(pad + (i / (vals.length - 1 || 1)) * (W - pad * 2)).toFixed(1), +(H - pad - ((v - min) / (max - min)) * (H - pad * 2)).toFixed(1)]);\n    all.push(pts);\n    const line = smooth(pts);\n    out += `<path class=\"mn-area${cls}\" d=\"${line} L ${pts[pts.length - 1][0]} ${H} L ${pts[0][0]} ${H} Z\"/>`;\n    out += `<path class=\"mn-line${cls}\" d=\"${line}\"/>`;\n    out += pts.map(([x, y], i) => `<circle class=\"mn-point${cls}\" data-i=\"${i}\" cx=\"${x}\" cy=\"${y}\" r=\"3\"/>`).join('');\n  });\n  out += `<line class=\"mn-chart-guide\" x1=\"0\" x2=\"0\" y1=\"0\" y2=\"${H}\" visibility=\"hidden\"/>`;\n  svg.innerHTML = out;\n  svg._mn = { series, pts: all, W };\n  if (svg._mnIndex != null) showPoint(svg, svg._mnIndex);\n}\n\n/* Hover, touch or arrow keys: a guide line at the nearest point and a tip with\n   its label and values. Labels: data-labels=\"Jan,Feb,…\"; series names:\n   data-names=\"This year,Last year\"; units: data-unit, a currency sign or a\n   unit ending in a space is a prefix (“€12”), anything else a suffix\n   (“12k”, “60 k€” with data-unit=\" k€\"). */\nconst escTip = (t) => String(t).replace(/[&<>\"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '\"': '&quot;' })[c]);\nconst fmt = (svg, v) => {\n  const u = svg.dataset.unit || '', n = Number(v).toLocaleString();\n  return !u ? n : /^[€$£¥]$/.test(u) || u.endsWith(' ') ? u + n : n + u;\n};\nfunction chartTip(svg) {\n  if (svg._mnTip) return svg._mnTip;\n  const host = svg.parentElement;\n  if (getComputedStyle(host).position === 'static') host.style.position = 'relative';\n  const tip = document.createElement('div');\n  tip.className = 'mn-chart-tip';\n  tip.setAttribute('role', 'status');\n  tip.hidden = true;\n  host.appendChild(tip);\n  return (svg._mnTip = tip);\n}\nfunction showPoint(svg, i) {\n  const d = svg._mn; if (!d) return;\n  const n = d.series[0].length;\n  i = Math.max(0, Math.min(n - 1, i));\n  svg._mnIndex = i;\n  const x = d.pts[0][i][0];\n  const guide = svg.querySelector('.mn-chart-guide');\n  guide.setAttribute('x1', x); guide.setAttribute('x2', x); guide.setAttribute('visibility', 'visible');\n  svg.querySelectorAll('.mn-point').forEach((c) => c.classList.toggle('mn-active', +c.dataset.i === i));\n  const labels = (svg.dataset.labels || '').split(',').map((s) => s.trim());\n  const names = (svg.dataset.names || '').split(',').map((s) => s.trim());\n  const tip = chartTip(svg);\n  tip.innerHTML = (labels[i] ? `<strong>${escTip(labels[i])}</strong>` : '') + d.series.map((vals, si) =>\n    `<span class=\"mn-chart-tip-row\"><i class=\"${si ? 'mn-s2' : ''}\"></i>${names[si] ? `${escTip(names[si])}: ` : ''}<b>${escTip(fmt(svg, vals[i]))}</b></span>`).join('');\n  tip.hidden = false;\n  // Place the tip beside the guide, flipped at the right edge.\n  const host = svg.parentElement, hb = host.getBoundingClientRect(), sb = svg.getBoundingClientRect();\n  const left = sb.left - hb.left + (x / d.W) * sb.width;\n  const flip = left + tip.offsetWidth + 12 > hb.width;\n  tip.style.left = `${flip ? left - tip.offsetWidth - 10 : left + 10}px`;\n  tip.style.top = `${sb.top - hb.top + 4}px`;\n}\nfunction hidePoint(svg) {\n  svg._mnIndex = null;\n  svg.querySelector('.mn-chart-guide')?.setAttribute('visibility', 'hidden');\n  svg.querySelectorAll('.mn-point.mn-active').forEach((c) => c.classList.remove('mn-active'));\n  if (svg._mnTip) svg._mnTip.hidden = true;\n}\nfunction nearest(svg, clientX) {\n  const d = svg._mn, b = svg.getBoundingClientRect();\n  const x = ((clientX - b.left) / b.width) * d.W;\n  let best = 0;\n  d.pts[0].forEach(([px], i) => { if (Math.abs(px - x) < Math.abs(d.pts[0][best][0] - x)) best = i; });\n  return best;\n}\nfunction interactive(svg) {\n  if (svg._mnWired) return; svg._mnWired = true;\n  if (!svg.hasAttribute('tabindex')) svg.setAttribute('tabindex', '0');\n  svg.addEventListener('pointermove', (e) => { if (svg._mn) showPoint(svg, nearest(svg, e.clientX)); });\n  svg.addEventListener('pointerdown', (e) => { if (svg._mn) showPoint(svg, nearest(svg, e.clientX)); });\n  svg.addEventListener('pointerleave', () => { if (document.activeElement !== svg) hidePoint(svg); });\n  svg.addEventListener('focus', () => showPoint(svg, svg._mnIndex ?? (svg._mn ? svg._mn.series[0].length - 1 : 0)));\n  svg.addEventListener('blur', () => hidePoint(svg));\n  svg.addEventListener('keydown', (e) => {\n    const n = svg._mn?.series[0].length || 0, i = svg._mnIndex ?? n - 1;\n    const to = { ArrowLeft: i - 1, ArrowRight: i + 1, Home: 0, End: n - 1 }[e.key];\n    if (to != null) { e.preventDefault(); showPoint(svg, to); }\n    if (e.key === 'Escape') hidePoint(svg);\n  });\n}\n\n/* Column and bar charts: the value shows on hover (CSS). Keyboard: the chart is\n   one tab stop; arrow keys move between bars and show each value. */\nfunction interactiveBars(chart) {\n  if (chart._mnWired) return; chart._mnWired = true;\n  const bars = () => [...chart.querySelectorAll(':scope > .mn-bar[data-value]')];\n  if (!bars().length) return;\n  if (!chart.hasAttribute('tabindex')) chart.setAttribute('tabindex', '0');\n  let i = -1;\n  const set = (k) => { const b = bars(); i = Math.max(0, Math.min(b.length - 1, k)); b.forEach((x, j) => x.classList.toggle('mn-active', j === i)); };\n  chart.addEventListener('keydown', (e) => {\n    const n = bars().length;\n    const horizontal = chart.classList.contains('mn-chart-h');\n    const map = horizontal ? { ArrowUp: i - 1, ArrowDown: i + 1 } : { ArrowLeft: i - 1, ArrowRight: i + 1 };\n    const to = { ...map, Home: 0, End: n - 1 }[e.key];\n    if (to != null) { e.preventDefault(); set(to); }\n  });\n  chart.addEventListener('focus', () => set(i < 0 ? 0 : i));\n  chart.addEventListener('blur', () => bars().forEach((x) => x.classList.remove('mn-active')));\n}\n\nregister('chart', (root) => {\n  each(root, '.mn-chart-bars', 'Bars', interactiveBars);\n  each(root, 'svg.mn-chart-line[data-values]', 'Line', (svg) => {\n    drawLine(svg);\n    interactive(svg);\n    new MutationObserver(() => drawLine(svg)).observe(svg, { attributes: true, attributeFilter: ['data-values', 'data-values-2'] });\n    let w = 0;\n    if ('ResizeObserver' in window) new ResizeObserver(([e]) => { const nw = Math.round(e.contentRect.width); if (nw && nw !== w) { w = nw; drawLine(svg); } }).observe(svg);\n  });\n});",
  "install": {
    "css": "https://mian.ro/dist/mian.css",
    "js": "https://mian.ro/dist/mian.js",
    "registryItem": "https://mian.ro/r/kpi.json"
  }
}
